Введение

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

О библиотеках и местах их обитания

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

Статические библиотеки

Статические библиотеки по своей сути являются самым прямым воплощением идеи библиотек. В процессе сборки компилятор, проходя по всем файлам проекта, создает объектные файлы, содержащие код и данные для каждой единицы трансляции. Статические библиотеки появились из идеи, что можно разделять код путем повторного использования общих объектных файлов. Такие библиотеки иногда называют «архивами», поскольку фактически это просто набор упакованных объектных файлов. Так в UNIX-подобных операционных системах они обладают расширением *.a от слова archive. В Windows статические библиотеки обычно имеют расширение *.lib, хотя такое же расширение можно заметить и у файлов с информацией об экспортируемых символах динамических библиотек, что вносит некоторую путаницу.

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

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

Однако, использование статических библиотек налагает на разработчиков определенные ограничения, часто не подходящие для реализации SDK. В том числе:

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

Динамические библиотеки

Развитием идеи разделения общего кода между независимыми приложениями стало появление динамических библиотек. Например, реализация системных либо широко используемых библиотек, таких как стандартная библиотека C++, в виде статических библиотек чрезвычайно накладна — массовое дублирование одинаковых частей кода среди всех приложений в системе привело бы к сокращению доступного дискового пространства и оперативной памяти. Кроме того, необходимость исправления реализации одной из функций в такой библиотеке означала бы неизбежную пересборку всех зависимых приложений.

Динамические библиотеки спроектированы для решения таких проблем. Обычно они имеют расширение *.dll в Windows, *.so в UNIX, *.dylib в macOS. В процессе сборки программы компоновщик, обнаруживая символ из разделяемой библиотеки, не включает его определение в исполняемый файл. Вместо этого, фиксируется имя символа и библиотеки, в которой он должен быть реализован. При работе процесса, операционная система просматривает список зависимостей, подгружая в память либо используя уже загруженный код нужных динамических библиотек. Разрешение символов в процессе работы приложения называется поздним связыванием.

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

Другие преимущества использования динамических библиотек:

  1. Экономия как дисковой, так и оперативной памяти за счет использования одного и того же кода.
  2. Доступ к общим ресурсам, разделяемым между всеми приложениями (строки, константы, изображения).
  3. Ускорение сборки приложения. Нет необходимости включать код динамической библиотеки в исполняемый файл.

Основные недостатки:

  1. Появляется необходимость поддержки обратной совместимости и версионирования библиотек.
  2. Необходимость предоставлять динамические библиотеки при развертывании приложения. Приложение не запустится, если в системе нет нужной библиотеки.
  3. Многочисленные проблемы связности библиотек, которые иногда обобщенно называют Dependency Hell. Зачастую проявляются в виде перекрестных зависимостей от разных версий одной и той же библиотеки.

Есть два способа подключения динамических библиотек к пользовательскому приложению:

  1. Динамическая линковка
  2. Динамическая загрузка

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

В случае динамической загрузки процесс во время своей работы явно вызывает загрузку библиотеки при помощи вызовов API операционной системы, таких как LoadLibrary() в Windows и dlopen() в Unix. В этом случае не происходит автоматического связывания символов с фактическими адресами в коде загруженной библиотеки. Процесс должен сам осуществить поиск нужного символа в памяти при помощи таких функций как GetProcAddress() или dlsym().

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

Плагины

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

На этом этапе мы подходим к двум важным темам — программный и двоичный интерфейс приложений, называемые так же API и ABI.

Об API, ABI и языке C

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

API - Application Programing Interface

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

ABI - Application Binary Interface

В C++ под термином ABI понимают детали реализации языковых конструкций на двоичном уровне, не специфицированных в стандарте языка. Стандарт C++ описывает общую модель языка, но не указывает на то, какие подходы использовать для её реализации. В качестве примера можно привести виртуальные функции. В стандарте есть описание того, какое поведение ожидается от виртуальных функций, но нет информации о том, как его реализовать. Расположение классов и их виртуальных таблиц в памяти, способ передачи параметров в функции (через стек или регистр), реализация возврата значения из функции, механизм исключений — все это описывается в ABI. Если два компилятора на одной платформе реализуют разные ABI, то их код будет несовместим на бинарном уровне. Иногда ABI изменяется даже между разными версиями одного компилятора.

Name Mangling

Стоит отдельно упомянуть такую особенность ABI, как Name Mangling. Методы и данные в приложениях на языке C++ имеют внутренние или декорированные имена, отличные от их имен в исходном коде. Декорированное имя — это символьная строка, в которой вместе с именем объекта закодирована дополнительная информация о типе, параметрах, соглашении о вызовах и других полезных для компилятора подсказках, помогающих найти правильный код функции.

Поскольку для языка C++ нет общепринятого ABI, при динамической загрузке библиотеки может возникнуть проблема поиска нужных символов из-за декорирования имен. Однако здесь на помощь приходит язык C. Директива препроцессора extern "C" позволяет отключить декорирование имени для экспортируемых интерфейсов библиотеки, так как если бы они были написаны на C.

Пример Name Mangling:

Calling Convention

Чтобы иметь возможность вызвать функцию из нашего SDK не достаточно знать одно лишь её имя. Такие важные особенности работы функций, как способ передачи параметров (через стек или через регистры), порядок их размещения и механизм реализации возвращенаемого значения, могут отличатся между разными компиляторами. В общем случае эти детали объединяют под термином Calling Convention.

К примеру, вот некоторые известные соглашения о вызове функций и их краткое описание:

Название Кто очищает стек Передача параметров
__stdcall Вызывающий код Через стек в обратном порядке (слева направо)
__fastcall Вызывающий код Первые два через регистры, остальные - в стеке. Все слева направо
__cdecl Вызывающий код Через стек в обратном порядке (слева направо)
__thiscall Вызывающий код Через стек справа налево. Указатель this сохраняется в регистре ecx.

Заметка об исключениях

Исключения, при их наличии, не должны покидать кода библиотеки. Реализация механизма исключения отличается между компиляторами и так же является частью ABI C++. Если функция в процессе своей работы должна сообщить об ошибке, она должна сделать это через возвращаемое значение.

Об экспортируемых и импортируемых символах

Общие принципы работы разделяемых библиотек одинаковы вне зависимости от платформы (Windows, Linux, macOS). Однако так же существуют и некоторые существенные различия.

Экспортируемые символы

Основное отличие заключается в том факте, что в библиотеках, собранных в UNIX-подобных системах, все символы всех объектных файлов библиотеки экспортируются автоматически, если они не запрещены для экспорта путем использования модификатора static. В Windows символы не экспортируются, если они не открыты для доступа явно.

Существует три способа экспортировать символ из *.dll:

  1. При помощи директивы __declspec(dllexport)
  2. При помощи опции компоновщика export:symbol
  3. При помощи специального *.def файла, содержащего символы для экспортирования.

Импортируемые символы

Кроме необходимости для динамических библиотек в Windows явно определять экспортируемые символы, разрешается так же явно указывать символы, подлежащие импорту в приложении. Это не обязательная процедура, но она указана как способствующая оптимизации скорости работы с DLL.

Распространенный подход решения проблем с экпортом/импортов нужных символов — использование макросов препроцессора. Пример:

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

Дополнительные файлы DLL

Еще один уровень сложности при работе с библиотеками Windows связан с тем фактом, что дополнительная информация о DLL (как, например, информация об экспортируемых символах) хранится в отдельных файлах. Среди них:

  • *.lib – файл импорта библиотеки, описывающий какой и где символ хранится в DLL. Создается только если библиотека экспортирует символы. При динамической линковке исполняемый файл, использующий DLL, обращается *.lib файлу для поиска символов.
  • *.exp - файл с информацией, необходимой для разрешения циклических зависимостей библиотек.
  • *.ilk — файл с информацией о статусе инкрементной компоновки.
  • *.def - файл определений, позволяющий управлять деталями скомпонованной библиотеки, включая экспорт символов.
  • *.res - файл ресурсов, используемых библиотекой.

В этом DLL отличнается от динамических библиотек в системах UNIX, где вся перечисленная информация обычно добавляется в сам файл библиотеки.

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

О стандартном подходе к построению переносимых интерфейсов

Как мы уже успели увидеть, отсутствие общепринятого ABI делает неудобным использование компонентов стандартной библиотеки C++ в открытых интерфейсах. В случае различий в реализации ABI для компилятора, используемого для сборки клиентского приложения, при использовании библиотеки может возникнуть неопределенное поведение.

Классическим подходом к построению переносимых интерфейсов является проектирование API библиотеки при помощи функций и типов языка C. В этом случае при необходимости произведения инициализации/деинициализации библиотеки вызываются соотвествующие функции. Еще одной необязательной особенностью подхода в стиле C является добавление к именам функций префикса, ассоциируемого с названием библиотеки. Это происходит из-за отсутствия возможности реализации namespace без декорирования имен в стиле C++. При необходимости оперирования объектами, создаваемыми библиотекой и хранящими в себе какое-то состояние, используется подход передачи непрозрачного указателя (хендлера) или структуры в вызовы функций из библиотеки.

Пример простого интерфейса:

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

Преимущества и недостатки

К преимуществам использования внешнего интерфейса SDK в стиле C можно отнести следующие пункты:

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

В недостатки можно записать следующее:

  • Клиентский код сам отвечает за правильную последовательность вызовов функций SDK. Например, если для работы библиотеки необходима первоначальная инициализация, нужная функция должная быть вызвана во внешнем коде. То же самое относится к освобождению ресурсов при окончании работы с библиотекой.
  • Необходимо явно вызывать функции создания и удаления объектов.

О расширенном подходе к построению переносимых интерфейсов

Из-за своих ограничений, подход в стиле C при проектировании интерфейса SDK может оказаться недостаточно гибким и удобным, как при работе с объектно-ориентированными компонентами стандартной библиотеки C++. Альтернативой может являться использование интерфейсных классов в составе API.

Интерфейсный класс — это класс, не имеющий данных и состоящий из чисто виртуальных функций. В случае использования интерфейсных классов может быть реализована следующая схема: библиотека предоставляет открытую функцию, которая создает и возвращает указатель на интерфейсный класс-фабрику, с помощью которого создаются экземпляры других классов. В коде библиотеки классы-реализации наследуются от интерфейсов, оставаясь полностью скрытыми и не зависимыми от клиентского кода. В данном подходе необходимо явно объявить только одну точку экспорта — функцию-фабрику. Весть остальной API библиотеки становится доступным за счет таблиц виртуальных функций. Такая схема помогает реализовать гибкие интерфейсы, менее ограниченные, чем при классическом подходе с использованием функций в стиле C. Тут нужно сделать замечание, что использование интерфейсных классов не освобождает от необходимости избегать применения в их методах компонентов стандартной библиотеки C++.

Несмотря на то, что применение интерфейсных классов позволяет использовать преимущества объектно-ориентированного подхода, для корректного проектирования SDK нужно помнить о еще одном важном ограничении: память, выделенная библиотекой под один из своих объектов, должна быть симметрично освобождена при помощи соответствующего кода из библиотеки. Это связано с тем фактом, что реализация работы операторов new() и delete() относится к ABI C++. В общем случае это правило можно сформулировать следующим образом: произведение любых операций в клиентском коде над объектом, созданным библиотекой, безопасно до тех пор, пока эти действия не зависят от реализации стандартной библиотеки.

Таким образом, проблема заключается в необходимости контролировать удаление объектов SDK. Здесь обычно используется один из двух подходов:

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

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

Почему это работает

Интерфейсный класс, не содержащий в себе данных и функций, представляет собой всего лишь виртуальную таблицу, то есть набор указателей на функции. Этим указателям присваиваются адреса реальных функций из кода библиотеки. Внешний код, работая с интерфейсным классом, вызывает соответствующую имплементацию функции из SDK. Почему такой подход работает для кода всех наиболее значимых компиляторов? Следует напомнить, что таблица виртуальных функций не является единственно возможным вариантом реализации работы виртуальных функций для языка C++.

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

Преимущества и недостатки

Использование интерфейсных классов в SDK дает следующие преимущества:

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

Основные недостатки:

  • Необходимо тщательно контролировать удаление объектов, созданных библиотекой.
  • Методы интерфейсных классов все еще не могут работать со стандартными типами и коллекциями языка С++.

Поддержка бинарной совместимости

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

Что можно менять в SDK без потери бинарной совместимости с клиентским кодом:

  • Добавлять новые функции.
  • Добавлять новые виртуальные методы в конец объявления класса, не имеющего наследников.
  • Добавлять новые интерфейсные классы.
  • Как угодно изменять классы и функции, не являющимися частью API (то есть не экспортируемыми во внешний код).

Какие изменения приведут к перекомпиляции с клиентским приложением:

  • Удаление существующих классов и функций, являющихся частью API.
  • Удаление существующих виртуальных методов.
  • Изменение иерархии интерфейсных классов (добавление или удаление наследования).
  • Изменение сигнатуры функций и методов.
  • Добавление новых виртуальных методов в середину класса.
  • Добавление виртуальных методов в класс, имеющий наследников.