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

Original author: Anteru
  • Translation

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 курса "Программист С". В рамках вебинара вы сможете познакомиться с нашими экспертами, подробно узнать о формате обучения, ознакомиться с программой курса, а также задать вопросы преподавателям.


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

Comments 5

    +1
    Вообще удивительно, как мало внимания уделяют API в наш век микросервисов, контейнеров, веб-приложений и cloud-инфраструктуры. На западе с этим как-то более внимательно, но тоже далеко не так хорошо, как могло бы.
    Если кто-то всё же хочет подтянутся по этому вопросу, наверно следует обратить внимание на книгу Непрерывное развитие API. Правильные решения в изменчивом технологическом ландшафте.
      0

      Как непривычно видеть Паскалевский стиль имён в С.

        0
        Хорошая идея, но в отрыве от остальных мер по написанию качественного кода профит оказывается катастрофично мал. Если библиотека плохо продумана, то упаковка функций в объект будет красива лишь первые несколько версий. Со временем же нагромождение API_v1, API_v2, API_v3 будет только вызывать шок из-за дублирования общих прототипов в смежных версиях API. (Либо я не понял идею).
        А изнутри библиотека как была, так и осталась, со всеми современными проблемами разработки. От упаковки API в объект основная масса кода не изменится.

        Я думаю, упор стоит больше делать на проектировку и организацию основного кода. Это само по себе даст большой положительный отклик в конечном API.
          +1
          По-моему, идея очень спорная.
          1. Версионирование API должно решаться версионированием библиотеки. Вместо этого предлагается тащить с библиотекой все старые версии API, что приведет к ужасному раздуванию кода библиотеки (по сути он будет включать в себя все выпущенные версии библиотеки, и баги тоже придется отдельно править во всех версиях API). При этом несовместимые изменения бывают не только на уровне прототипов функций, но и на уровне семантики, поэтому нам придется включать API_v12 и API_v13 в которых таблица функций идентична, просто их поведение отличается.

          Собственно, пример относящийся к упоминаемому в статье Vulcan — OpenGL vs Direct3D. Microsoft с каждой версией делали несовместимую версию библиотеки, что позволяло своевременно избавляться от устаревших функций. OpenGL вместо этого пытался сохранить совместимость, так что API раздувалось и раздувалось — большинство функций оперируют глобальным состоянием (а те которые не оперируют есть только в последних версиях), загрузка вершин по одной через glVertex, fixed pipeline с туманом и освещением, всё это должно было поддерживаться создателями драйверов в произвольных комбинациях. Какие-то попытки с Core\Compatibility были, но итог мы все знаем, вместо OpenGL5 появился Vulcan.
          В статье предлагается некий промежуточный вариант — чтобы библиотека была одна, при этом первая функция возвращала таблицу со всеми вызовами direct3d1.0, вторая — со всеми вызовами direct3d2.0, и так далее. Звучит довольно монструозно, а в чем польза вообще непонятно.

          2. Усложняется написание байндингов.
          Если байндинги пишет не разработчик библиотеки а тот кто собирается ее использовать, то он лишается даже минимальной проверки на соответствие имен. Достаточно пропустить одну функцию (из-за невнимательности при ручной генерации или ошибки парсинга при автоматической) и будут вызываться вообще не те функции.
          Насчет автоматической генерации — если мне, скажем, надо написать байндинги для Ruby для библиотеки загрузки png, то в случае «классической» библиотеки я возьму сишный хедер, возьму документацию к Ruby Fiddle и копипастом легко напишу требуемый десяток строчек. А вот если это библиотека с предлагаемой системой API, то я даже не буду пытаться. Какие-то XML файлы содержащие описание таблицы маршрутизации, читать к ним документацию чтобы понять как написать автоматический генератор, плюс разбираться как вызывать из этой таблицы функции, за что, я просто хотел загрузить пару картинок.
            0
            Советую обратить внимание на VirtualBox XPCOM. Аналогично Mozilla, на Windows это просто системный COM. В отличие от Mozilla, двоично стабилен. Библиотек на нём особо не делают, так что и с генераторами привязок не густо, но это хоть можно изменить.

            Only users with full accounts can post comments. Log in, please.