Pull to refresh
827.91
OTUS
Цифровые навыки от ведущих экспертов

Проектирование C API

Reading time 5 min
Views 6.2K
Original author: Anteru

C API все так же распространены, как и раньше. На C написано много библиотек или библиотек с предоставлением C API. Есть также биндинги для различных языков программирования, что делает язык C стандартом де-факто для переносимых API. Тем не менее многие API не соответствуют базовым принципам проектирования, и, похоже, что за последние годы в этой области мало чего изменилось. Недавно мне пришлось поработать над современным C API, но затем появился Vulkan с несколькими интересными идеями. Самое время взглянуть на современные варианты разработки C API.

Архитектура имеет значение

Проектирование API имеет большое значение. Я уже писал об этом ранее. Но мне до сих пор попадаются много API, при использовании которых мне хочется встретиться с автором и серьезно поговорить с ним. Сегодня мы не будем обсуждать такие базовые вещи, как бинарная совместимость (ABI, Application Binary Interface), управление версиями, обработка ошибок и тому подобное — вместо этого мы рассмотрим способы предоставления API клиенту.

Я предполагаю, что вы разрабатываете API для shared object/DLL. Часто в C точки входа API экспортируются напрямую. Вы просто помечаете их как видимые, выбираете схему именования и все. Клиент либо линкуется напрямую с вашей библиотекой, либо загружает точки входа вручную и вызывает их. Практически все C API выглядят именно так. Посмотрите на sqlite, libpng, Win32, ядро ​​Linux — это все примеры этого паттерна.

Проблемы такого подхода

Так в чем же проблемы этого подхода? Вот, например, некоторые из них:

  • Версионирование

  • Загрузка

  • Расширяемость

Давайте разберемся с каждым из них.

Версионирование API

Для любого API вы неизбежно столкнетесь с необходимостью изменения сигнатуры функции. Если вас волнует совместимость API и ABI, то в API придется добавлять новую точку входа — это классическая причина, по которой мы видим так много myFunctionEx или myFunctionV2. Если вы предоставляете точки входа напрямую, то этого не избежать.

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

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

Загрузка API

Вопрос загрузки API состоит в том, как разработчик начинает работу с вашим API. Достаточно часто вы напрямую ссылаетесь на импортируемую библиотеку, а затем ожидаете, что shared object или DLL будут экспортировать те же символы, как и раньше. Это затрудняет динамическое использование библиотеки (т.е. ее использование только при необходимости). Конечно, вы можете сделать трюки отложенной загрузки с помощью линковщика, но что, если у вас нет библиотеки импорта? В этом случае вы будете использовать что-то вроде библиотеки диспетчеризации, которая загрузит все точки входа вашего API. Например, так делает загрузчик OpenCL или GLEW. Таким образом, ваш клиент изолируется на 100% от библиотеки, но ценой некоторого бойлерплейт кода.

Решения этой проблемы нацелены на сокращение лишнего кода. GLEW генерирует все функции загрузки из XML-описаний. OpenCL просто требует предоставления единственной точки входа, которая заполняет таблицу диспетчеризации. И это подводит нас к последней проблеме — расширяемости.

Расширяемость API

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

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

Vulkan также имеет декларативную версию API, хранящуюся в файле vk.xml со всеми расширениями, из которого можно генерировать необходимые определения функций. Это значительно сокращает бойлерплейт код, но все же требует от пользователей запроса точек входа. Хотя можно было бы сгенерировать загрузчик полностью автоматически, как это делает GLEW.

API с диспетчеризацией и генерацией

Размышляя над вышеуказанными проблемами, я пришел к выводу, что в идеале мы хотим получить следующее:

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

  • Группировка всех функций одной версии вместе. В этом случае переключение версии приведет к ошибкам во время компиляции.

  • Возможность реализации нового набора функций поверх исходного API (слои) — то есть возможность замены отдельных точек входа.

Если вы подумали о классах C++ и COM, то вы не так уж далеко от истины. Давайте посмотрим на следующий подход к разработке API: 

  • Вы предоставляете точку входа, которая возвращает таблицу диспетчеризации для API. 

  • Таблица диспетчеризации содержит все точки входа для API.

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

Как может выглядеть такой API? Например, так:

struct ImgApi
{
    int (*LoadPng) (ImgApi* api, const char* filename,
        Image* handle);
    int (*ReadPixels) (ImgApi* api, Image* handle,
        void* target);
    // or
    int (*ReadPixels) (Image* handle, void* target);

    // Various other entry points
};

// public entry points for V1_0
int CreateMyImgIOApiV1_0 (ImgApi** api);
int DestroyMyImgIOApiV1_0 (ImgApi* api);

Решает ли это наши проблемы? Давайте проверим:

  • Уменьшение количества точек входа — их две. Это работает и для динамической, и для статической загрузки.

  • Все функции сгруппированы! Мы можем добавить ImgApiV2, не нарушив работу старых клиентов, и все ошибки будут найдены во время компиляции.

  • Слои, как вы видите, тоже можно реализовать! Мы просто инстанцируем новый ImgApi и связываем его с исходным. В этом случае единственная сложность возникает из-за объединения в цепочку таких объектов, как Image и поэтому понадобится возможность обращаться из них к таблице диспетчеризации.

Выглядит так, что мы нашли решение вышеуказанных проблем. И действительно, недавно я разрабатывал библиотеку с использованием такого API, и реализация оказалась действительно проста. Но каковы же недостатки такого решения? В принципе, я вижу только один — вызовы через таблицу диспетчеризации. Это приводит к одной лишней переадресации и небольшому увеличению количества кода.

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

Как вы видите, здесь вступает в игру генерация кода. Я думаю, что для изменяющихся API полезно использовать декларативное описание API: XML, JSON или что-то подобное. Есть много вещей, которые можно сгенерировать автоматически, поэтому вам следует подумать об этом с самого начала.

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


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


Tags:
Hubs:
+9
Comments 5
Comments Comments 5

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS