Lambdas and Callback Management (Anonymous Functions)

cardimg

In C, callbacks are handled using Function Pointers. This works, but it's rigid—you can't easily pass "state" (variables) into the callback without messy void* casting.

Lambdas (introduced in C++11) are Function Objects that you can write inline. They solve the "State" problem elegantly.

Think of a Lambda as a Disposable Function: You write it right where you need it, it captures the variables it needs from the surrounding scope, executes, and then it's gone.

Syntax & Anatomy

The syntax looks like this: [Captures] (Parameters) { Body }

  • [ ] Capture Clause: "What variables from the outside world do I need?"

  • ( ) Parameters: "What arguments does the caller pass me?" (Just like a normal function).

  • { } Body: The code to execute.

int main() {
    int threshold = 50;

    // A Lambda stored in a variable 'check'
    // It captures 'threshold' so it can use it inside.
    auto check = [threshold](int value) {
        if (value > threshold) {
            printf("Alert! Value %d > %d\n", value, threshold);
        }
    };

    check(60); // Prints "Alert! Value 60 > 50"
}

The Power of Captures ([ ])

This is where Lambdas beat Function Pointers.

  • [ ] (Empty): Captures nothing. Behaves exactly like a standard C function.

  • [=] (By Value): Copies all used local variables into the lambda. Safe, but read-only by default.

  • [&] (By Reference): Accesses the actual variables. Dangerous if the lambda runs after the variable scope ends.

  • [this]: Captures the current object. Essential for using member variables inside a callback.

class Button {
    int id = 1;
public:
    void onClick() {
        // Capture 'this' to access 'id' inside the lambda
        auto callback = [this]() {
            printf("Button %d Clicked\n", this->id);
        };
        callback();
    }
};

Storage & embedded constraints

How do you store a Lambda? This is the tricky part in firmware.

A. The "Captureless" Optimization (Zero Cost)

If a lambda has empty captures [], the compiler treats it identical to a raw function pointer.

  • Cost: Zero RAM overhead. No Heap.

  • Use: Compatible with legacy C drivers expecting void (*ptr)(int).

// Legacy C Driver
void register_irq( void (*cb)(int) );

void setup() {
    // Works perfectly! Decays to function pointer.
    register_irq( [](int code) { 
        printf("IRQ %d\n", code); 
    }); 
}

B. The "Capturing" Challenge (std::function)

If a lambda has captures (e.g., [x]), it is no longer just code; it is an object with data. It won't fit in a raw function pointer.

  • Solution 1 (Standard): Use std::function<void(int)>.

    • Warning: This often uses malloc (Heap) to store the captured variables. Risky in strict embedded.

  • Solution 2 (Template): The "Zero Cost" way for C++.

// Template approach: The compiler generates a custom type for the lambda.
// No Heap. No Virtual calls. Maximum speed.
template <typename Callback>
void run_task(Callback cb) {
    cb();
}

int main() {
    int x = 10;
    // We can capture 'x' without worrying about malloc
    run_task( [x](){ printf("%d", x); } );
}

Relevance in Embedded/Firmware

1. Asynchronous Event Handling

Instead of writing 10 different tiny functions (handleButton1, handleButton2), you define the logic inline during initialization.

button1.onPress( [](){ led.on(); } );
button2.onPress( [](){ motor.stop(); } );

2. Custom Iterators

Running logic over a collection of sensors.

// 'sensors' is an array of objects
std::for_each(sensors.begin(), sensors.end(), [](Sensor& s) {
    s.calibrate(); // Runs for every sensor
});

3. Scoped Locks (RAII)

You can use a lambda to define a critical section that automatically executes logic.

execute_atomic( []() {
    // Interrupts are disabled automatically by the wrapper
    critical_variable++;
}); // Interrupts re-enabled here

Common Pitfalls (Practical Tips)

Pitfall

Details

❌ Dangling Reference

Capturing a local variable by reference [&] and passing the lambda to a Timer/ISR. When the Timer fires later, the function has returned, the stack is gone, and the reference points to garbage. Crash.

❌ Size Overhead

A capturing lambda is an object. If you capture 10 integers [=], the lambda object is 40 bytes. Copying this around takes CPU cycles.

mutable keyword

By default, [=] captures are const (read-only). If you want to modify a captured copy inside the lambda, you must write []() mutable { ... }.

✅ Use auto

Lambda types are unpronounceable compiler-generated secrets. Always use auto to hold them.

Summary Checklist

  • Use [] (empty) if you need compatibility with C function pointers.

  • Use [this] if writing a lambda inside a class method.

  • Avoid [&] (reference capture) for asynchronous tasks (Timers/ISRs).

  • Prefer Templates over std::function to avoid Heap usage.

 

 

 

Concept understood? Let's apply and learn for real

Practice now