Part 29: Constraining Callbacks with Concepts + Full Code Walkthrough
Continuing from the previous article: we have set up the skeleton for the
Buttontemplate class. In this article, we tackle the final C++ feature—using concepts to constrain the callback parameter types—and then do a complete walkthrough of the entiremain.cppcall chain from start to finish.
The Callback Type Problem
poll_events() accepts a callback function as a parameter, invoking it whenever a confirmed button state change occurs. The problem is that the C++ template parameter Callback can be any type—a function pointer, a lambda, a function object, or even an integer (if you make a coding mistake).
Without concepts, what happens if we pass a callback with the wrong signature?
// 错误的回调:接受 int 而不是 ButtonEvent
button.poll_events([](int x) { /* ... */ }, HAL_GetTick());2
The compiler attempts to instantiate the poll_events() code, discovers that int cannot be constructed from Pressed when calling cb(Pressed{}), and then reports an error. But the error message might look like this:
error: no match for call to '(lambda) (Pressed)'
note: candidate expects 1 argument of type 'int', got 'Pressed'
in instantiation of 'void Button::poll_events(Callback&&, uint32_t, uint32_t)
[with Callback = main()::<lambda(int)>; ...]'2
3
4
A few lines of template instantiation stack trace paired with obscure type information. While this is much better than the SFINAE (Substitution Failure Is Not An Error) errors of C++98, it still isn't intuitive enough.
Concepts: One-Line Constraint, Clear Errors
template <typename Callback>
requires std::invocable<Callback, ButtonEvent>
void poll_events(Callback&& cb, uint32_t now_ms, uint32_t debounce_ms = 20) {2
3
requires std::invocable<Callback, ButtonEvent> is a concepts constraint. It tells the compiler: an object of type Callback must be callable with a ButtonEvent argument.
If we pass a callback with the wrong signature:
button.poll_events([](int x) { /* ... */ }, HAL_GetTick());The compiler reports the error before template instantiation:
error: constraint 'std::invocable<lambda, ButtonEvent>' not satisfied
note: the expression 'std::invocable<lambda, ButtonEvent>' evaluated to 'false'2
One sentence explains it all: your callback does not satisfy the std::invocable<Callback, ButtonEvent> constraint. There is no need to dig through template instantiation stacks—a constraint failure directly tells you where the problem lies.
What Does std::invocable Mean?
std::invocable<F, Args...> is a concept defined in the C++20 <concepts> header. It checks whether f(args...) is a valid call expression, given an object f of type F.
For std::invocable<Callback, ButtonEvent>:
Callbackis the lambda or function object you passed inButtonEventisstd::variant<Pressed, Released>- The constraint requires:
cb(ButtonEvent{})must be a valid call
Examples of valid callbacks:
// Lambda 接受 ButtonEvent
button.poll_events([](device::ButtonEvent e) { /* ... */ }, HAL_GetTick());
// Lambda 接受 auto(泛型 lambda)
button.poll_events([](auto&& e) { /* ... */ }, HAL_GetTick());
// Lambda 接受 Pressed(variant 的一个选项)— 这不行!
// std::invocable<Callback, ButtonEvent> 检查的是用 ButtonEvent 调用,不是 Pressed
button.poll_events([](device::Pressed e) { /* ... */ }, HAL_GetTick()); // 编译错误2
3
4
5
6
7
8
9
Concepts vs. SFINAE
Before concepts, constraining template parameters relied on SFINAE (Substitution Failure Is Not An Error):
// SFINAE 方式 — 丑陋且难以理解
template <typename Callback,
typename = std::enable_if_t<std::is_invocable_v<Callback, ButtonEvent>>>
void poll_events(Callback&& cb, uint32_t now_ms, uint32_t debounce_ms = 20);2
3
4
The principle behind SFINAE is that if the condition in std::enable_if_t is false, the template is silently removed from the candidate list, and the compiler looks for other matching overloads. Only if no match is found at all does it report a "no matching function" error—and this error is usually accompanied by dozens of lines of template instantiation stack traces.
Concepts elevate constraints to first-class citizens of the language: the requires clause directly declares the constraint, the compiler directly checks it, and a constraint failure directly reports the constraint's name. There is no need to understand how SFINAE works under the hood.
Is Callback&& an Rvalue Reference?
void poll_events(Callback&& cb, ...)Callback&& looks like an rvalue reference, but it is actually a forwarding reference. When Callback is a template parameter, the meaning of Callback&& depends on the argument passed in:
- If an lvalue is passed (such as a named lambda variable):
Callbackis deduced asLambda&, andCallback&&becomesLambda& &&, which collapses toLambda&(an lvalue reference). - If an rvalue is passed (such as a temporary lambda):
Callbackis deduced asLambda, andCallback&&is simplyLambda&&(an rvalue reference).
Therefore, Callback&& can accept anything—lvalues, rvalues, const, and non-const. This is exactly what we want: users can pass a temporary lambda or a named function object.
Why not use const Callback&? Because a const reference cannot invoke a non-const operator(). Although our lambda does not modify captured variables, maintaining generality is safer.
In this scenario, we did not use std::forward<Callback>(cb)—because the callback is only invoked once inside poll_events(), so perfect forwarding is unnecessary. If cb is an lvalue, we just call it directly; if it is an rvalue, we also just call it directly. The role of the forwarding reference here is simply to "accept any callable object type," rather than to "perfectly forward" it.
Full Code Walkthrough
Now let's walk through the execution flow of main.cpp from start to finish, and see what each line of code is doing.
#include "device/button.hpp"
#include "device/button_event.hpp"
#include "device/led.hpp"
#include "system/clock.h"
extern "C" {
#include "stm32f1xx_hal.h"
}2
3
4
5
6
7
Header file inclusions. button.hpp indirectly includes gpio.hpp. extern "C" wraps the HAL header files to ensure the C++ compiler uses C linkage rules when looking up HAL functions (as covered in Part 12 of the LED tutorial).
int main() {
HAL_Init();
clock::ClockConfig::instance().setup_system_clock();2
3
System initialization. Exactly the same as the LED tutorial: initialize the HAL library, and configure the system clock to 64MHz.
device::LED<device::gpio::GpioPort::C, GPIO_PIN_13> led;
device::Button<device::gpio::GpioPort::A, GPIO_PIN_0> button;2
Object construction. These two lines each do three things:
LED Construction:
GPIOClock::enable_target_clock()—if constexprenables the GPIOC clocksetup(Mode::OutputPP, NoPull, Low)— configures PC13 as push-pull output- The
ledobject is ready, providing theon(),off(), andtoggle()interfaces
Button Construction:
GPIOClock::enable_target_clock()—if constexprenables the GPIOA clocksetup(Mode::Input, PullUp, Low)— configures PA0 as input with pull-up resistorstatic_assertvalidates the pin number — passes at compile time- The
buttonobject is ready, with the state machine's initial state set toBootSync
while (1) {
button.poll_events(
[&](device::ButtonEvent event) {
std::visit(
[&](auto&& e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, device::Pressed>) {
led.on();
} else {
led.off();
}
},
event);
},
HAL_GetTick());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Main loop. Each iteration does one thing: calls button.poll_events().
HAL_GetTick() gets the current timestamp (in milliseconds) and passes it to the state machine for time-based evaluation.
The callback lambda [&](device::ButtonEvent event) captures led by reference. When the state machine confirms a state change, it invokes this lambda, where the parameter event is std::variant<Pressed, Released>.
std::visit dispatches based on the type held by event:
- If it is
Pressed: callsled.on() - If it is
Released(theelsebranch): callsled.off()
The Complete Call Chain:
main() 循环
→ poll_events(lambda, HAL_GetTick())
→ is_pressed() → read_pin_state() → HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0)
→ switch(state_) 状态机判断
→ 确认变化时: cb(Pressed{}) 或 cb(Released{})
→ lambda 被调用,event = ButtonEvent
→ std::visit(lambda2, event)
→ if constexpr: led.on() 或 led.off()
→ HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, ...)2
3
4
5
6
7
8
9
From the moment the user presses the button to the LED lighting up, the sequence is: physical level change → IDR register update → HAL_GPIO_ReadPin() read → state machine debounce confirmation → Pressed event triggered → std::visit dispatch → led.on() → HAL_GPIO_WritePin() → ODR register update → LED turns on.
The entire process involves no virtual functions, no heap allocation, and no exception handling. Every layer is a compile-time resolved inline call.
Looking Back
This article completes the final piece of our C++ refactoring puzzle:
- Concepts (
requires std::invocable<Callback, ButtonEvent>) constrain the callback signature, providing clear compilation errors - Forwarding references
Callback&&accept any callable object - Full code walkthrough of the entire call chain from
main()toHAL_GPIO_WritePin()
So far, we have fully refactored the button control code using C++. The next article serves as the conclusion to this series—covering EXTI (External Interrupt) driven buttons, along with a summary of common pitfalls and practice exercises.