Как я писал под Флиппер на Си-с-классами
- 2023-10-19
- ru
- CC BY-SA 4.0
- habr
Мой Флиппер дошел до меня больше полугода назад, но что-то под него написать я собрался только сейчас. Его API рассчитаны на язык С — а у меня с ним опыта не очень много. Но проблем с тулингом не возникло — у Флиппера есть своя система сборки, которая скачала мне нужный тулчейн и сгенерировала настройки для IDE.
А для написания кода я решил использовать все же не C, а C++ — точнее, даже "Си-с-классами". На мой взгляд, затуманенный языками более высокого уровня, такой подход получился удобнее, чем писать на чистом C. Результат можно увидеть в моем репозитории, а в этой статье я попытаюсь описать, какие конкретные фичи языка я использовал, и как именно они мне помогли.
Сразу скажу, что моей целью не было написание полноценных C++-биндингов для API флиппера. Конечно же, обернув функции здешнего API в классы, используя конструкторы и деструкторы вместо _alloc()
- и _free()
-функций, а некоторые интерфейсы переписав совсем, я смог бы писать намного более идиоматичный код с точки зрения современного C++. Однако это потребовало бы намного больших затрат времени на написание, документацию и поддержку. Вместо этого, я искал от C++ способы как можно более простым способом избавиться от самых больших неудобств — некоторыми из которых и хочу с вами поделиться.
#Пространства имен
В сишных API функции и константы, как правило, называются с длинными префиксами: mylib_mything_get_foo()
, MylibMyenumFirst
. Порой это делает код чрезвычайно многословным — особенно в тех случаях, когда из контекста функции вполне понятно, что get_foo()
мы вызываем именно для mything
из библиотеки mylib
. Поэтому, прежде всего, я хотел раскидать имена по отдельным пространствам имен.
Для типов это можно сделать, просто добавив типы-аласы. Вроде таких:
Для функций все чуть более интересно:
Подход с ссылками имеет сразу несколько плюсов. Во-первых, constexpr
-ссылки гарантированно хорошо инлайнятся, превращаясь просто в вызовы оригиналов — в моих тестах у меня не было не-встроенных вызовов. Во-вторых, это - в отличие, например, от написания оберток — не требует повторять сигнатуру исходной функции. Более того, моя IDE даже подтянула для таких ссылок документацию оригиналов:
Для того, чтобы не писать в каждой строчке constexpr inline auto&
, я определил для этого макрос FURI_HH_ALIAS
. Для макросов в C++, к сожалению, пространств имен нет, поэтому его пришлось назвать с префиксом.
Остались только enum
ы. Идиоматичным C++ было бы использовать для них enum class
— но проблема в том, что это будут другие типы, и один в другой сами по себе конвертироваться не будут. Поэтому остановился на алиасах и константах в отдельном namespace
, для которых использовал все тот же макрос FURI_HH_ALIAS
:
В итоге, мои заголовочные файлы стали выглядеть как-то так:
mutex.hh
#Владеющие указатели
Следующее неудобство, от которого я хотел бы избавиться — необходимость не забывать вручную освобождать ресурсы. Возможно, я просто слишком привык к языкам, в которых есть using
, деструкторы или хотя бы try-finally
, но мне действительно бывает сложно следить за этим самому. Особенно в случае ранних возвратов из функций, или передачи владения указателем.
Стандартный "владеющий" указатель в C++ — это std::unique_ptr
. Но он мне не подошел по нескольким причинам.
Первая довольно прозаична: std::unique_ptr<T>
не конвертируется автоматически в T*
, для этого нужно явно вызывать метод .get()
. В API Флиппера владение указателем, как правило, в функцию не передается — исключая _free()
-функции, конечно. А писать везде .get()
получается слишком многословно.
Другая проблема немного сложнее, и связана с тем, как именно устроены API Флиппера и логика.
У std::unique_ptr
есть возможность указать вторым параметром шаблона объект Deleter
, который будет отвечать за то, как именно будет освобожден указатель. Логика достаточно простая: для типа T
у него должен быть operator()(T*)
, который этот указатель и освободит.
Сначала я хотел завести свою структуру Deleter
, и просто перегружать ее operator()
для каждого из типов в API:
inline void
Но довольно быстро выяснилась очень обидная особенность API Флиппера.
Как правило, когда в сишных API фигурируют указатели, они часто "непрозрачные" — не предназначены для разыменования пользователем, а только для использования с этим же самым API. Они обычно реализуются так:
// объявление структуры без указания полей
typedef struct MyStruct MyStruct;
// использование в объявлениях функций
MyStruct* ;
Но в заголовках Флиппера часто встречается вот такое:
// furi/core/mutex.h
typedef void FuriMutex;
// furi/core/timer.h
typedef void FuriTimer;
// furi/code/message_queue.h
typedef void FuriMessageQueue;
Подвох в этом в том, что с точки зрения системы типов все эти объявления — это один и тот же тип! А это значит, что по ним не работают перегрузки, и просто взять и перегрузить один и тот же Deleter::operator()
для них не получится.
Пользуясь случаем: если это читают разработчики Флиппера — pls fix.
А я, в итоге, написал небольшую обертку над стандартным std::unique_ptr
. Вот так выглядят объявления владеющих указателей:
Вот так их можно использовать:
А вот так выглядит реализация:
own.hh
#defer
Недостаток RAII я чувствовал не только для выделения-освобождения памяти, но и многих других действий. Например, захвата и освобождения мьютексов. Или удаления ViewPort
из GUI перед вызовом view_port_free()
— этот баг я искал довольно долго. Писать для каждого такого случая свой guard-класс мне не хотелось, поэтому позаимствовал идею из других языков — реализовал defer
.
Использовать его можно примерно так:
// здесь мьютекс освобожден
// здесь ViewPort удален
Реализация ничем не примечательна — идея довольно стара:
defer.hh
#Колбеки
В API Флиппера довольно много функций принимают колбеки — для того, чтобы уведомлять о событиях, или запускать код в другом потоке. Организовано это довольно стандартно для сишных API:
// в функцию передается указатель на колбек, а также указатель на ее контекст:
void ;
// когда колбек будет вызван, этот контекст ему будет передан:
typedef void ;
Неудобств в таком подходе два.
Во-первых, это означает, что колбеки бывает нужно определять довольно далеко от места их использования. Для небольших колбеков это очень неудобно:
void
void
Вернее, означало в C — а в C++ есть "положительные" лямбды! Они не могут захватывать переменные, но превращаются в указатель на функцию.
void
Вторая проблема связана с типизацией. Единственный способ в сишном API сделать функцию обобщенной относительно контекста колбека — обращаться с ним как с void*
. Но это приводит к необходимости кастов, и к возможности случайно скастить не в тот тип.
В случае типов FuriMutex
и FuriTimer
, как мы видели выше, компилятор при этом даже не ругнется.
Поэтому я решил написать свою простую структуру-обертку для пары "колбек-контекст"... но очень быстро наткнулся на еще одно не очень удачное — с точки зрения C++ — решение в API Флиппера:
// где-то контекст передается первым аргументом...
typedef void ;
// ...а где-то — последним!
typedef void ;
Я очень долго ломал голову над тем, как написать одну обертку на оба случая, но потом плюнул и просто написал две:
callback.hh
Кроме этого, в самих функциях, принимающих колбеки, тоже есть неконсистентность: в некоторых колбек с контекстом — это последние аргументы, в некоторых нет, в а некоторых между ними стоит еще один аргумент. Поэтому для таких функций я все-таки решил написать обертки. Вот пример:
inline auto
inline auto
Использовать их можно как-то так:
// со статическим методом
;
// с лямбдой и дополнительным синтаксическим сахаром
_timer = ;
#Заключение
Это были некоторые из примеров того, как в написании приложения для Флиппера мне помог Си-с-классами — а точнее, почти без классов, но с неймспейсами, RAII и так далее. Еще несколько примеров есть в моем репозитории — например, вот матчинг по типам событий с помощью std::variant
. Однако мне кажется, что их достаточно, чтобы продемонстрировать, что C++ может помочь в около-эмбеддед разработке. По крайней мере, если применять дозированно.