Как и почему я писал для Флиппера на Си-с-классами

от автора

Мой Флиппер дошёл до меня больше полугода назад, но что-то под него написать я собрался только сейчас. Его API рассчитаны на язык С — а у меня с ним опыта не очень много. Но проблем с тулингом не возникло — у Флиппера есть своя система сборки, которая скачала мне нужный тулчейн и сгенерировала настройки для IDE.

А для написания кода я решил использовать всё же не 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;   } } 

В итоге, мои заголовочные файлы стали выглядеть как-то так:

mutex.hh

#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)); } 

А вот так выглядит реализация:

own.hh

#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 удалён 

Реализация ничем не примечательна — идея довольно стара:

defer.hh

#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); 

Я очень долго ломал голову над тем, как написать одну обёртку на оба случая, но потом плюнул и просто написал две:

callback.hh

#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-канала ?


ссылка на оригинал статьи https://habr.com/ru/articles/768658/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *