I'm experimenting: can I use a method of a particular object as a callback? This can be used in typical callback situations, e.g.: in a UART driver. But also in interrupt handlers.
- lightweight: small runtime cost, low firmware hit. Can be used for tiny embedded systems.
- reusable: don't assume parameters and types for the handler, when writing this little framework -> C++ templates
- hip -> object oriented, modern C++ constructs
- type safe -> again C++ templates
- allow a classic C function, a lambda, a static class method or an object method as handler
example: Raspberry Pico blinky using this design
#include <new> import callbackmanager; const uint32_t delay_ms = 250; callbackmanager::Callback<void> cb; int main() { while (true) {
project: 2133.pico_callback_blink.zip |
Callback template class
The template callback class. This is in essence the whole mechanism. A generic class that allows you to register and call event handler functions, lambda's and methods.
#include <functional> // concept guards what types of return values we can handle template<typename R> concept Callbackable = std::is_void<R>::value || std::is_arithmetic_v<R> || std::is_class_v<R>; template <Callbackable R, typename... Args> class Callback { using callbackfunction_t = std::function<R(Args...)>; public: Callback() : callback_(nullptr), isSet(false){} inline void set(callbackfunction_t callback) { callback_ = callback; isSet = true; } inline void unset() { callback_ = nullptr; isSet = false; } inline bool is_set() { return isSet; } /* * R can be an arithmetic type, an object, or void */ inline R operator()(Args... args) { if constexpr (std::is_void<R>::value) { // void if (!is_callback_set) { return; } else { (callback_)(args...); } } else if constexpr (std::is_class<R>::value) { // object if (!is_callback_set) { return R(); } else { return (callback_)(args...); } } else { // not void nor object if (!is_callback_set) { return 0; // R can only be a arithmetic type. 0 should work as default. } else { return (callback_)(args...); } } } private: callbackfunction_t callback_; bool isSet; };
This generic class allows for handlers that pass any amount of parameters. No assumption of the types.
When we use the class in our code, we 'll tell it what the parameters are, and their type. For this blog, I am going to pass it 2 integers. The return value is also an integer.
Callback<int, int, int> cb;
In the declaration above, the first int is the return value type, the 2 others are parameter 1 and 2. Once you write this code, the object cb will only accept handler methods that take two integers as parameter, and return an integer.
Example of class that will handle the callback
This could be a gio class (blinky a led when an interrupt occurred?), a GUIwidget, or a business object. Typical guidelines apply: a handler should be swift. In particular if it's an interrupt handler.
class MyClass { public: int handler(int num1, int num2) { return num1 + num2; } };
The example does not do a lot today. It just sums up the two integers passed. But as a POC this will do.
Actual code using the two
#include <cstdio> int main() { MyClass myClass; Callback<int, int, int> cb; // Use a lambda to capture myClass and call the member method cb.set([&myClass](int num1, int num2) -> int { return myClass.handler(num1, num2); }); int a = 4; int b = 5; int o = cb(a, b); printf("Value: %i\n", o); // We might be on an embedded system, use printf() and not std::cout }
Why use a lambda to pair the handler and Callback ? In the code, I did not learn the MyClass how to register itself. And the Callback doesn't know anything about who or what it 'll call. These classes (and their objects) are unaware of each other. Loose coupling.
In the application logic, I tie the two together. The lambda function code (the anonymous function, starting with the [, and ending with the } above), is used as the vehicle to hold the code that the Callback object will execute when needed. The lambda function could as well be a straigtforward piece of code, not related to any object:
Or we could pass completely different executable code. As long as the lambda matches the signature given when instantiating the Callback class. |
In an embedded system,
- myClass would be one of your application objects.
- cb could be part of a driver, or the HAL layer
- cb.set() would be called by you, to register the handler
- cb.call() would be done by the driver, or HAL ISR, when it wants to notify you (call your callback handler)
In the case of this example, it will execute myClass.handler(). If you 'd have many MyClass objects, it would call the exact one that's registered in with set().
Example use of the callback class in an embedded application
Anything special?
This approach allows to write a reusable callback system. It is type safe. But it does not assume what the type is of the object it 'll call the handler off. (Or if it's actually an object).
It's a small amount of code, that performs a function that you regularly need in an embedded or event driven design.
What does type-safe mean in this context? If your interrupt handler assumes a function that passes 2 integers, and you pass it a callback function that has different parameters, And you get all of that, without having to specify the number of variables, and their types, in the Callback class code. We get a reusable class. Type safe. Without the need to foresee all possible combinations. |
this library now also works for Arduino (tested with IDE2, and an Arduino MKT GSM 1400)
For Arduino, you can download callbackmanager.h to your computer, then use Sketch -> Add File ... to get the callbackmanager included to your project.
I added a sketch to test the callback manager to this gist: test_arduino.ino
|
shortcuts:
- I assume fixed return value type. A richer implementation can use template for that too, and should allow void. edit: this works
- I pass arguments by value. A better design would also allow pass by reference. And to pass const references. edit: this works
- should I use a shared pointer, so that I can never call the handler of an object that no longer exists? (solved by alternative: provide an unset() method). edit: I no longer use pointers
- it only allows to call one handler. Good for ISR use. But in an event driven design, you may want to register more handlers. I prefer just one handler. simple scope, simple responsibility.
Inspiration:
- I started from this example by Geoffrey Hunter.
- mbed's Callback class, although I didn't use any of the code
- cplusplus' tutorial for std::function, to validate if it works for static class member, object member, classic C function and lambda function.
Code is available on github. Additional assets available on Gist.
CMakeLists.txt to build an example program:
cmake_minimum_required(VERSION 3.28) project(callbackmanager C CXX ASM) # GoogleTest requires at least C++14 set(CMAKE_CXX_STANDARD 26) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fmodules-ts -fcommon") add_executable(${CMAKE_PROJECT_NAME}) target_sources(${CMAKE_PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/example/callback_examples.cpp ) target_sources(${CMAKE_PROJECT_NAME} PUBLIC FILE_SET cxx_modules TYPE CXX_MODULES FILES ${CMAKE_CURRENT_SOURCE_DIR}/callbackmanager.cpp ) target_link_libraries( ${CMAKE_PROJECT_NAME} )
I test this code in C++ callbacks and templates: make the Callback class call an object member, a static member, a C function and a pure lambda function
edit 20250208: I converted it to a c++ module: modern C++ modules: convert existing example from .h to module