А для написания кода я решил использовать всё же не C, а C++ — точнее, даже «Си-с-классами». На мой взгляд, затуманенный языками более высокого уровня, такой подход получился удобнее, чем писать на чистом C. Результат можно увидеть в моём репозитории, а в этой статье я попытаюсь описать, какие конкретные фичи языка я использовал, и как именно они мне помогли.
Сразу скажу, что моей целью не было написание полноценных C++-биндингов для API флиппера. Конечно же, обернув функции здешнего API в классы, используя конструкторы и деструкторы вместо
_alloc()
— и _free()
-функций, а некоторые интерфейсы, переписав совсем — я смог бы писать намного более идиоматичный код, с точки зрения современного C++. Однако это потребовало бы намного больших затрат времени на написание, документацию и поддержку. Вместо этого, я искал от C++ способы как можно более простым способом избавиться от самых больших неудобств — некоторыми из которых и хочу с вами поделиться.▍ Пространства имён
В сишных API функции и константы, как правило, называются с длинными префиксами:
mylib_mything_get_foo()
, MylibMyenumFirst
. Порой это делает код чрезвычайно многословным — особенно в тех случаях, когда из контекста функции вполне понятно, что get_foo()
мы вызываем именно для mything
из библиотеки mylib
. Поэтому, прежде всего, я хотел раскидать имена по отдельным пространствам имён.Для типов это можно сделать, просто добавив типы-аласы. Вроде таких:
namespace furi {
using Timer = ::FuriTimer;
using Mutex = ::FuriMutex;
}
Для функций всё чуть более интересно:
namespace furi::mutex {
constexpr inline auto& acquire = ::furi_mutex_acquire;
}
Подход со ссылками имеет сразу несколько плюсов. Во-первых,
constexpr
-ссылки гарантированно хорошо инлайнятся, превращаясь просто в вызовы оригиналов — в моих тестах у меня не было не-встроенных вызовов. Во-вторых, это — в отличие, например, от написания обёрток — не требует повторять сигнатуру исходной функции. Более того, моя IDE даже подтянула для таких ссылок документацию оригиналов:Для того, чтобы не писать в каждой строчке
constexpr inline auto&
, я определил для этого макрос FURI_HH_ALIAS
. Для макросов в C++, к сожалению, пространств имён нет, поэтому его пришлось назвать с префиксом.Остались только
enum
ы. Идиоматичным C++ было бы использовать для них enum class
— но проблема в том, что это будут другие типы, и один в другой сами по себе конвертироваться не будут. Поэтому остановился на алиасах и константах в отдельном `namespace`, для которых использовал всё тот же макрос FURI_HH_ALIAS
:namespace furi::mutex {
using Type = ::FuriMutexType;
namespace type {
FURI_HH_ALIAS Normal = ::FuriMutexTypeNormal;
FURI_HH_ALIAS Recursive = ::FuriMutexTypeRecursive;
}
}
В итоге, мои заголовочные файлы стали выглядеть как-то так:
#pragma once
#include <furi.h>
#include "furi/macros.hh"
#include "furi/own.hh"
namespace furi {
using Mutex = ::FuriMutex;
using MutexOwn = Own<::FuriMutex, ::furi_mutex_free>;
namespace mutex {
using Type = ::FuriMutexType;
namespace type {
FURI_HH_ALIAS Normal = ::FuriMutexTypeNormal;
FURI_HH_ALIAS Recursive = ::FuriMutexTypeRecursive;
}
FURI_HH_ALIAS alloc = ::furi_mutex_alloc;
FURI_HH_ALIAS free = ::furi_mutex_free;
FURI_HH_ALIAS acquire = ::furi_mutex_acquire;
FURI_HH_ALIAS release = ::furi_mutex_release;
FURI_HH_ALIAS get_owner = ::furi_mutex_get_owner;
}
}
▍ Владеющие указатели
Следующее неудобство, от которого я хотел бы избавиться — необходимость не забывать вручную освобождать ресурсы. Возможно, я просто слишком привык к языкам, в которых есть
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 Deleter<Mutex>operator()(Mutex* m) {
::furi_mutex_free(m);
}
Но довольно быстро выяснилась очень обидная особенность API Флиппера.
Как правило, когда в сишных API фигурируют указатели, они часто «непрозрачные» — не предназначены для разыменования пользователем, а только для использования с этим же самым API. Они обычно реализуются так:
// объявление структуры без указания полей
typedef struct MyStruct MyStruct;
// использование в объявлениях функций
MyStruct* mystruct_alloc();
Но в заголовках Флиппера часто встречается вот такое:
// 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
. Вот так выглядят объявления владеющих указателей:namespace furi {
using MutexOwn = Own<::FuriMutex, ::furi_mutex_free>;
using TimerOwn = Own<::FuriTimer, ::furi_timer_free>;
using MessageQueueOwn = Own<::FuriMessageQueue, ::furi_message_queue_free>;
}
Вот так их можно использовать:
{
using namespace furi;
// создание
MutexOwn m = mutex::alloc();
// использование
auto thread_id = mutex::get_owner(m);
// освобождение — автоматически
// но если очень нужно, всё ещё можно руками
mutex::free(std::move(m));
}
А вот так выглядит реализация:
#pragma once
#include <memory>
namespace furi {
namespace own {
template<class T> using Free = void(&)(T*);
}
template<class T, own::Free<T> F> class Own {
struct _Destroy {
void operator()(T* ptr) { F(ptr); }
};
std::unique_ptr<T, _Destroy> _ptr;
public:
Own(): _ptr(nullptr, _Destroy{}) {}
Own(T* ptr): _ptr(ptr, _Destroy{}) {}
Own(const Own&) = delete;
Own(Own&&) = default;
Own& operator=(const Own&) = delete;
Own& operator=(Own&&) = default;
operator T*() { return _ptr.get(); }
operator const T*() const { return _ptr.get(); }
T* get_mut() const { return _ptr.get(); }
};
}
▍ defer
Недостаток RAII я чувствовал не только для выделения-освобождения памяти, но и многих других действий. Например, захвата и освобождения мьютексов. Или удаления
ViewPort
из GUI перед вызовом view_port_free()
— этот баг я искал довольно долго. Писать для каждого такого случая свой guard-класс мне не хотелось, поэтому позаимствовал идею из других языков — реализовал defer
.Использовать его можно примерно так:
{
mutex::acquire(m);
defer (mutex::release(m));
// ...
}
// здесь мьютекс освобождён
{
gui::add_view_port(gui, vp);
defer (gui::remove_view_port(gui, vp));
// ...
}
// здесь ViewPort удалён
Реализация ничем не примечательна — идея довольно стара:
#pragma once
#include "furi/macros.hh"
namespace furi {
template<class F> class Defer {
F _fn;
public:
Defer(F &&fn): _fn(fn) {}
~Defer() { _fn(); }
};
#define FURI_HH_CONCAT_IMPL(x,y) x##y
#define FURI_HH_CONCAT(x,y) FURI_HH_CONCAT_IMPL(x,y)
#define defer(code) auto FURI_HH_CONCAT(_defer_, __COUNTER__) = Defer{[&]{ code; }}
}
▍ Колбеки
В API Флиппера довольно много функций принимают колбеки — для того, чтобы уведомлять о событиях, или запускать код в другом потоке. Организовано это довольно стандартно для сишных API:
// в функцию передаётся указатель на колбек, а также указатель на её контекст:
void furi_timer_pending_callback(FuriTimerPendigCallback callback, void* context, uint32_t arg);
// когда колбек будет вызван, этот контекст ему будет передан:
typedef void (*FuriTimerPendigCallback)(void* context, uint32_t arg);
Неудобств в таком подходе два.
Во-первых, это означает, что колбеки бывает нужно определять довольно далеко от места их использования. Для небольших колбеков это очень неудобно:
void my_callback(void* ctx) { /*...*/ }
void my_long_function() {
// ...
// ...
// ...
mylib_use_callback(ctx, my_callback);
// ...
// ...
// ...
}
Вернее, означало в C — а в C++ есть «положительные» лямбды! Они не могут захватывать переменные, но превращаются в указатель на функцию.
void my_long_function() {
// ...
// ...
// ...
mylib_use_callback(ctx, +[](void* ctx) { /*...*/ });
// ...
// ...
// ...
}
Вторая проблема связана с типизацией. Единственный способ в сишном API сделать функцию обобщённой относительно контекста колбека — обращаться с ним как с
void*
. Но это приводит к необходимости кастов, и к возможности случайно скастить не в тот тип.В случае типов
FuriMutex
и FuriTimer
, как мы видели выше, компилятор при этом даже не ругнётся.Поэтому я решил написать свою простую структуру-обёртку для пары «колбек-контекст»… но очень быстро наткнулся на ещё одно не очень удачное — с точки зрения C++ — решение в API Флиппера:
// где-то контекст передаётся первым аргументом...
typedef void (*FuriTimerPendigCallback)(void* context, uint32_t arg);
// ...а где-то — последним!
typedef void (*ViewPortDrawCallback)(Canvas* canvas, void* context);
Я очень долго ломал голову над тем, как написать одну обёртку на оба случая, но потом плюнул и просто написал две:
#pragma once
namespace furi {
namespace cb {
template<class... As> using FnPtr = void(*)(As...);
}
// здесь контекст — первый агрумент
template<class... As> struct Cb {
using FnPtr = cb::FnPtr<void*, As...>;
void* ctx;
FnPtr fn_ptr;
Cb(): ctx(nullptr), fn_ptr(nullptr) {}
Cb(FnPtr fn_ptr): ctx(nullptr), fn_ptr(fn_ptr) {}
template<class C> Cb(C* ctx, cb::FnPtr<C*, As...> fn_ptr)
: ctx(static_cast<void*>(ctx))
, fn_ptr(reinterpret_cast<FnPtr>(fn_ptr))
{}
void operator()(As... args) {
if (fn_ptr) fn_ptr(ctx, args...);
}
};
// а здесь — второй
// хотел честно сделать последним,
// но вывод типов почему-то сломался
template<class A1, class... As> struct Cb2 {
using FnPtr = cb::FnPtr<A1, void*, As...>;
void* ctx;
FnPtr fn_ptr;
Cb2(): ctx(nullptr), fn_ptr(nullptr) {}
Cb2(FnPtr fn_ptr): ctx(nullptr), fn_ptr(fn_ptr) {}
template<class C> Cb2(C* ctx, cb::FnPtr<A1, C*, As...> fn_ptr)
: ctx(static_cast<void*>(ctx))
, fn_ptr(reinterpret_cast<FnPtr>(fn_ptr))
{}
void operator()(A1 a1, As... args) {
if (fn_ptr) fn_ptr(a1, ctx, args...);
}
};
}
Кроме этого, в самих функциях, принимающих колбеки, тоже есть неконсистентность: в некоторых колбек с контекстом — это последние аргументы, в некоторых нет, в а некоторых между ними стоит ещё один аргумент. Поэтому для таких функций я всё-таки решил написать обёртки. Вот пример:
inline auto alloc_cb(Type type, Cb<> cb) {
return alloc(cb.fn_ptr, type, cb.ctx);
}
inline auto set_draw_callback_cb2(ViewPort *vp, Cb2<Canvas*> cb2) {
return set_draw_callback(vp, cb2.fn_ptr, cb2.ctx);
}
Использовать их можно как-то так:
// со статическим методом
set_draw_callback_cb2(_vp, {this, _draw});
// с лямбдой и дополнительным синтаксическим сахаром
_timer = timer::alloc_cb(
Periodic,
Ctx{this} >> +[](SecondTimer *self) { self->_on_tick(); }
);
▍ Заключение
Это были некоторые из примеров того, как в написании приложения для Флиппера мне помог Си-с-классами — а точнее, почти без классов, но с неймспейсами, RAII и так далее. Ещё несколько примеров есть в моём репозитории — например, вот матчинг по типам событий с помощью
std::variant
. Однако мне кажется, что их достаточно, чтобы продемонстрировать, что C++ может помочь в около-эмбеддед разработке. По крайней мере, если применять дозированно.Узнавайте о новых акциях и промокодах первыми из нашего Telegram-канала ????
Комментарии (8)
eao197
24.10.2023 09:51+16Спасибо за интересную статью!
Но позволю себе позанудствовать: на моей памяти "Си-с-классами" было принято называть C++ до появления в нем шаблонов. У вас же шаблоны используются вполне себе активно, местами только они и используются ;)
Так что вы описали вполне себе modern C++, особенно вот в этом фрагменте (а ведь многие еще себе и C++17 позволить не могут):namespace furi::mutex { constexpr inline auto& acquire = ::furi_mutex_acquire; }
а не "Си-с-классами".
Впрочем, полезность и интересность статьи это никак не умаляет.
tenzink
24.10.2023 09:51+7Для того, чтобы не писать в каждой строчке
constexpr inline auto&
, я определил для этого макросFURI_HH_ALIAS
.Мне кажется, что польза от подобных макросов, если вы планируете компилировать код там где есть и где нет constexpr. Макросы только для экономии десятка нажатий клавиш, IMO, замусоривают код и ухудшают читабельность. Хотя если это проект для себя, то неважно
boojum
24.10.2023 09:51+1Я так и не понял, что в итоге автор хотел написать или написал.
Какую недостающую фичу Флиппера он пытался реализовать.
iliazeus Автор
24.10.2023 09:51Целью было просто познакомиться с API на непривычном мне языке, и написать хоть что-нибудь. В итоге написал очень простое приложение-таймер; его код тоже есть в репозитории.
bfDeveloper
defer - штука хорошая на общий случай, но для частных лучше написать своие RAII объекты. тогда
Превратится в
Да, нужно писать по своему lock на каждый случай, но зато на строку меньше, не нужен макрос и точно невозможно забыть освободить. defer можно забыть написать, а c одной строкой ошибки уже не будет