Мой Флиппер дошёл до меня больше полугода назад, но что-то под него написать я собрался только сейчас. Его 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-канала ????

Комментарии (8)


  1. bfDeveloper
    24.10.2023 09:51
    +9

    defer - штука хорошая на общий случай, но для частных лучше написать своие RAII объекты. тогда

    mutex::acquire(m);
    defer (mutex::release(m));

    Превратится в

    mutex::lock lock(m);

    Да, нужно писать по своему lock на каждый случай, но зато на строку меньше, не нужен макрос и точно невозможно забыть освободить. defer можно забыть написать, а c одной строкой ошибки уже не будет


  1. eao197
    24.10.2023 09:51
    +16

    Спасибо за интересную статью!
    Но позволю себе позанудствовать: на моей памяти "Си-с-классами" было принято называть C++ до появления в нем шаблонов. У вас же шаблоны используются вполне себе активно, местами только они и используются ;)
    Так что вы описали вполне себе modern C++, особенно вот в этом фрагменте (а ведь многие еще себе и C++17 позволить не могут):

    namespace furi::mutex {
      constexpr inline auto& acquire = ::furi_mutex_acquire;
    }
    

    а не "Си-с-классами".

    Впрочем, полезность и интересность статьи это никак не умаляет.


    1. DrMefistO
      24.10.2023 09:51
      +1

      Я, когда увидел, что именно автор называет Си-с-классами, тут же прокрутил статью до низу и начал искать Ваш коментарий.


      1. eao197
        24.10.2023 09:51

        :)

        Надо сказать, что у автора получился кликбейтный заголовок в хорошем смысле этого слова. Как бы странно это не звучало :)


  1. tenzink
    24.10.2023 09:51
    +7

    Для того, чтобы не писать в каждой строчке constexpr inline auto&, я определил для этого макрос FURI_HH_ALIAS.

    Мне кажется, что польза от подобных макросов, если вы планируете компилировать код там где есть и где нет constexpr. Макросы только для экономии десятка нажатий клавиш, IMO, замусоривают код и ухудшают читабельность. Хотя если это проект для себя, то неважно


  1. tenzink
    24.10.2023 09:51
    +1

    Спасибо за статью, мне кажется, что C++-почти без классов - это прекрасно.


  1. boojum
    24.10.2023 09:51
    +1

    Я так и не понял, что в итоге автор хотел написать или написал.

    Какую недостающую фичу Флиппера он пытался реализовать.


    1. iliazeus Автор
      24.10.2023 09:51

      Целью было просто познакомиться с API на непривычном мне языке, и написать хоть что-нибудь. В итоге написал очень простое приложение-таймер; его код тоже есть в репозитории.