Functional programming in C++ means building logic from pure functions (same input, same output, no side effects), immutable data, and functions you pass around like values. C++ isn't a purely functional language, but lambdas, , constexpr, std::optional, std::variant and C++20 ranges make the style practical, even on a microcontroller with a few kilobytes of RAM.
Picture this. Your firmware runs perfectly on the bench. Then, once every few thousand power cycles, the device does something impossible. After days of digging, you find the culprit: a global variable that the main loop was reading while an interrupt was halfway through updating it.
Functional programming in C++ won’t fix every bug like that, but it gives you habits that make them much harder to write: pure functions, read-only data and small pieces that snap together. You don’t have to leave C++, rewrite your codebase or adopt a new philosophy to get the benefits.
Functional programming (FP) is a style where you build programs by combining functions that behave like math functions: the same input always gives the same output, and nothing else in the program changes along the way.
Five ideas carry most of the weight:
A pure function works like a calculator. Press 7 × 6 and you get 42 every time, and it never beeps the buzzer or dims the display as a side effect. Composition works like a conveyor belt: each station does one job and hands the result to the next.
The mindset shift looks like this:
Not purely. C++ is multi-paradigm, so procedural, object-oriented, generic and functional code can live in the same project. A language like Haskell stops you from mutating state. C++ trusts you, which is both its power and its danger. For firmware, that means you can adopt exactly as much functional style as your hardware can afford.
| Feature | Why it matters in firmware | Since |
| Lambdas | Write small callbacks and predicates right where you use them | C++11 |
| std::function, std::bind | Store and adapt callables (use with care on MCUs) | C++11 |
| Generic lambdas (auto parameters), relaxed constexpr | One lambda for many types; real logic at compile time | C++14 |
| constexpr lambdas, std::optional, std::variant, std::visit, std::invoke, std::transform_reduce | Safer results, sum types, compile-time pipelines | C++17 |
| Ranges and views, std::span, concepts, std::bind_front, consteval | Lazy pipelines with the pipe operator, read-only views | C++20 |
| std::expected, monadic std::optional, std::move_only_function | Error handling as plain values | C++23 |
Functional habits solve problems firmware engineers actually have:
One honest caveat: functional style doesn’t replace volatile, atomics or critical sections for data that is genuinely shared with interrupts. It shrinks how much of your code needs them.
A pure function returns the same output for the same input and touches nothing outside itself: no globals, no hardware registers, no printing.
// BAD: hidden input (g_calibration) and hidden output (it changes on every call)
static int g_calibration = 0;
int read_temperature() {
g_calibration++; // side effect
return read_adc() + g_calibration; // same call, different answer next time
}
// GOOD: everything it needs comes in as an argument, and it only returns a value
constexpr std::uint32_t adc_to_mv(std::uint16_t counts) {
return (static_cast<std::uint32_t>(counts) * 3300u) / 4095u; // 12-bit ADC, 3.3 V reference
}
// Free, compile-time unit tests
static_assert(adc_to_mv(0) == 0);
static_assert(adc_to_mv(2048) == 1650);
static_assert(adc_to_mv(4095) == 3300);
Ask three questions to spot an impure function:
If any answer is “yes” (or “no” for the last one), it’s impure.
What you gain:
In embedded work, immutability doesn’t mean copying a 1 KB buffer every time something changes. It means being explicit about who is allowed to change what.
// Fixed at build time; on most Cortex-M builds this lives in flash, not RAM
constexpr std::array<std::uint16_t, 4> kAdcChannels{0, 1, 4, 5};
// A read-only view: the function can look at the samples but cannot modify them (C++20)
std::uint32_t average(std::span<const std::uint16_t> samples);
A lambda is a small unnamed function you write right where you need it. The compiler turns it into a tiny object with a call operator, which is why it can be inlined so well.
int offset = 10;
// [capture](parameters) -> return_type { body }
auto add_offset = [offset](int x) -> int { return x + offset; };
int y = add_offset(5); // y == 15
The capture list decides what the lambda can see:
| Capture | Meaning | Use it when |
| [] | Captures nothing | Stateless helpers; converts to a plain function pointer |
| [x] | Copies x into the lambda | Small values; safe even if the lambda outlives the scope |
| [&x] | Refers to x | The lambda runs immediately, for example inside an algorithm call |
| [=] / [&] | Copies or references everything used | Short-lived lambdas; avoid in stored callbacks |
| [v = std::move(y)] | Moves y in (C++14) | Move-only or expensive objects |
A captureless lambda converts to a plain function pointer, which makes it handy for C-style HAL callbacks:
using IrqCallback = void (*)();
void register_callback(IrqCallback cb); // a C-style API, like many vendor HAL callbacks
register_callback([] { toggle_led(); }); // a captureless lambda becomes a plain function pointer
A higher-order function takes a function as an argument, returns one, or both. If you’ve ever passed a comparison lambda to std::sort, you’ve already used one.
Here’s a practical one: retrying a flaky operation.
template <typename Op>
bool retry(Op op, int max_attempts) {
for (int attempt = 0; attempt < max_attempts; ++attempt) {
if (op()) {
return true;
}
}
return false;
}
// Usage: read 2 bytes from an I2C sensor, trying up to 3 times
std::uint8_t buf[2];
const bool ok = retry([&] { return i2c_read(0x48, buf, 2); }, 3); // i2c_read: your driver function
| Option | Runtime cost | Heap use | Can hold state? | Best for |
| Function pointer | One indirect call | None | No | C APIs, vector tables, HAL callbacks |
| Template parameter (lambda or functor) | Usually inlined, near zero | None | Yes | Algorithms, drivers, hot paths |
| std::function | Indirect call plus type-erasure overhead, larger code | May allocate for larger captures | Yes | Host tools, tests, non-real-time code |
| Fixed-footprint wrappers (etl::delegate, SG14 inplace_function) | Small and predictable | None | Yes (some only reference the callable) | Stateful callbacks on bare metal |
#include
bool i2c_write(std::uint8_t addr, std::uint8_t reg, std::uint8_t value); // your driver
auto write_sensor_reg = std::bind_front(i2c_write, std::uint8_t{0x48}); // address fixed once
write_sensor_reg(0x01, 0xA0); // i2c_write(0x48, 0x01, 0xA0)
A small lambda does the same job, is just as clear, and works on C++11. Prefer either over the older std::bind.
Composition means feeding the output of one function into the next. Each piece stays tiny, testable and reusable.
// C++17 (constexpr lambdas)
constexpr std::int32_t mv_to_tenths_c(std::uint32_t mv) {
return static_cast<std::int32_t>(mv) - 500; // TMP36-style sensor: 500 mV offset, 10 mV per degree C
}
template <typename F, typename G>
constexpr auto compose(F f, G g) {
return [f, g](auto x) { return f(g(x)); }; // returns a NEW function: x -> f(g(x))
}
constexpr auto adc_to_temp = compose(mv_to_tenths_c, adc_to_mv); // counts -> mV -> tenths of a degree C
static_assert(adc_to_temp(std::uint16_t{1000}) == 305, "1000 counts should read 30.5 C");
You may already be doing functional programming without the label. The standard algorithms are the classic functional tools:
| Functional idea | C++ tool | Header | Plain-English meaning |
| map | std::transform | Apply a function to every element | |
| filter | std::copy_if | Keep only the elements that pass a test | |
| fold / reduce | std::accumulate, std::reduce | Combine all elements into one value | |
| any / all / none | std::any_of, std::all_of, std::none_of | Ask a yes/no question about the whole set | |
| count | std::count_if | Count elements that pass a test | |
| zip and fold | std::transform_reduce | Multiply-and-add work, such as a FIR filter |
Here’s the same job written both ways:
std::array<std::uint16_t, 5> readings{1000, 0, 1012, 995, 0};
// Imperative: you manage the loop, the index and the running total
std::uint32_t total = 0;
for (std::size_t i = 0; i < readings.size(); ++i) {
if (readings[i] > 0) {
total += readings[i];
}
}
// Functional: you say WHAT you want (fold the valid readings into one sum)
const std::uint32_t total_fold = std::accumulate(
readings.begin(), readings.end(), std::uint32_t{0},
[](std::uint32_t acc, std::uint16_t v) { return v > 0 ? acc + v : acc; });
Honest take: for a one-step loop like this, both versions are fine. The payoff arrives when you chain several steps. Here are two more building blocks:
std::array<std::uint16_t, 4> raw{1000, 1012, 995, 1003};
std::array<std::uint32_t, 4> mv{};
std::transform(raw.begin(), raw.end(), mv.begin(), adc_to_mv); // map: counts -> millivolts
const bool any_high = std::any_of(mv.begin(), mv.end(),
[](std::uint32_t v) { return v > 3000; }); // any reading above 3.0 V?
On fixed-size arrays you can't shrink the container, so copy_if into a spare buffer (and keep the count) is the cleanest way to filter. You'll see that in the full example below.
Ranges let you chain steps with |, like Unix commands, without creating temporary arrays.
#include // C++20
constexpr bool is_valid_sample(std::uint16_t counts) {
return counts > 0 && counts < 4095; // reject stuck-at-0 and saturated readings
}
const std::array<std::uint16_t, 8> raw{1000, 1012, 4095, 995, 1003, 0, 1008, 997};
auto good_mv = raw
| std::views::filter(is_valid_sample) // keep trustworthy samples
| std::views::transform(adc_to_mv); // convert each to millivolts
for (std::uint32_t mv : good_mv) {
// use mv (with this data: 805, 815, 801, 808, 812, 803)
}
Many firmware projects build with -fno-exceptions. Functional style handles failure as a plain return value instead.
#include // C++23
enum class SensorError { Timeout, CrcMismatch, OutOfRange };
std::expected<std::uint16_t, SensorError> read_sensor(); // your driver
constexpr std::expected<std::uint32_t, SensorError> check_range(std::uint32_t mv) {
if (mv > 3300) return std::unexpected(SensorError::OutOfRange);
return mv;
}
auto result = read_sensor()
.transform(adc_to_mv) // runs only if the read succeeded
.and_then(check_range); // may turn a success into an error
if (result) {
use_millivolts(*result);
} else {
handle_error(result.error());
}
std::variant holds exactly one of several types, and std::visit handles each type with its own code.
#include // C++17
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded; // helper needed before C++20
struct SetLed { std::uint8_t pin; bool on; };
struct SetPwm { std::uint8_t channel; std::uint16_t duty; };
struct Sleep { std::uint32_t ms; };
using Command = std::variant<SetLed, SetPwm, Sleep>;
void execute(const Command& cmd) {
std::visit(overloaded{
[](const SetLed& c) { gpio_write(c.pin, c.on); }, // your HAL functions
[](const SetPwm& c) { pwm_set_duty(c.channel, c.duty); },
[](const Sleep& c) { delay_ms(c.ms); }
}, cmd);
}
constexpr asks the compiler to compute a result at build time. You get lookup tables with no startup code and no RAM, plus bugs caught before the firmware ever runs.
// C++17
constexpr std::uint8_t crc8_step(std::uint8_t crc) {
for (int bit = 0; bit < 8; ++bit) {
crc = (crc & 0x80) ? static_cast<std::uint8_t>((crc << 1) ^ 0x07)
: static_cast<std::uint8_t>(crc << 1);
}
return crc;
}
constexpr std::array<std::uint8_t, 256> make_crc8_table() {
std::array<std::uint8_t, 256> table{};
for (std::size_t i = 0; i < table.size(); ++i) {
table[i] = crc8_step(static_cast<std::uint8_t>(i));
}
return table;
}
constexpr auto kCrc8Table = make_crc8_table(); // built by the compiler, stored as read-only data
constexpr std::uint8_t crc8(const std::uint8_t* data, std::size_t len) {
std::uint8_t crc = 0;
for (std::size_t i = 0; i < len; ++i) {
const std::uint8_t index = static_cast<std::uint8_t>(crc ^ data[i]);
crc = kCrc8Table[index];
}
return crc;
}
// Build-time self-test: the standard CRC-8 check value for "123456789" is 0xF4
constexpr std::uint8_t kCheck[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'};
static_assert(crc8(kCheck, sizeof kCheck) == 0xF4, "CRC-8 self-test failed");
More compile-time ideas worth trying:
The most practical way to apply functional programming in firmware is to split your code into two layers. The core makes decisions with pure functions. The shell is a thin layer that reads hardware, calls the core and writes hardware.
[ Shell: read hardware ] –> [ Pure core: filter -> convert -> decide ] –> [ Shell: write hardware ]
(impure) (pure, testable on a PC) (impure)
Here’s a tiny thermostat that follows the pattern. Notice that heater_next takes the previous state as an argument and returns the new one, so there’s no hidden variable inside it:
// PURE CORE
constexpr bool heater_next(bool heater_on, std::int32_t temp_x10) {
constexpr std::int32_t kLow = 240; // 24.0 C
constexpr std::int32_t kHigh = 260; // 26.0 C
if (temp_x10 <= kLow) return true; // too cold: heater ON
if (temp_x10 >= kHigh) return false; // warm enough: heater OFF
return heater_on; // inside the band: keep the previous state
}
static_assert(heater_next(false, 230) == true);
static_assert(heater_next(true, 270) == false);
static_assert(heater_next(true, 250) == true);
static_assert(heater_next(false, 250) == false);
// IMPERATIVE SHELL (adc_read, gpio_write, delay_ms come from your HAL)
void control_loop() {
bool heater_on = false;
while (true) {
const std::uint16_t raw = adc_read(TEMP_CHANNEL); // impure: talks to hardware
const std::int32_t temp = adc_to_temp(raw); // pure
heater_on = heater_next(heater_on, temp); // pure: old state in, new state out
gpio_write(HEATER_PIN, heater_on); // impure: talks to hardware
delay_ms(100); // impure
}
}
Here is everything so far in one file. It filters out bad ADC samples, averages the rest and converts the result to a temperature. Save it as pipeline.cpp and build it with g++ -std=c++17 -Wall -Wextra -O2 pipeline.cpp -o pipeline.
#include
#include
#include
#include
#include
#include
// ---------- Pure building blocks ----------
constexpr std::uint32_t adc_to_mv(std::uint16_t counts) {
return (static_cast<std::uint32_t>(counts) * 3300u) / 4095u; // 12-bit ADC, 3.3 V reference
}
constexpr std::int32_t mv_to_tenths_c(std::uint32_t mv) {
return static_cast<std::int32_t>(mv) - 500; // TMP36: 500 mV offset, 10 mV per degree C
}
constexpr bool is_valid_sample(std::uint16_t counts) {
return counts > 0 && counts < 4095; // reject stuck-at-0 and saturated readings
}
template <typename F, typename G>
constexpr auto compose(F f, G g) {
return [f, g](auto x) { return f(g(x)); }; // returns a new function: x -> f(g(x))
}
constexpr auto adc_to_temp = compose(mv_to_tenths_c, adc_to_mv);
// ---------- Compile-time self-checks (they cost zero flash) ----------
static_assert(adc_to_mv(2048) == 1650, "mid-scale should be half of 3.3 V");
static_assert(adc_to_temp(std::uint16_t{1000}) == 305, "1000 counts should be 30.5 C");
int main() {
constexpr std::size_t N = 8;
const std::array<std::uint16_t, N> raw{1000, 1012, 4095, 995, 1003, 0, 1008, 997};
// 1) FILTER: keep only trustworthy samples (fixed-size buffer, no heap)
std::array<std::uint16_t, N> valid{};
const auto valid_end = std::copy_if(raw.begin(), raw.end(), valid.begin(), is_valid_sample);
const auto count = static_cast<std::size_t>(valid_end - valid.begin());
// 2) FOLD: average of the good samples
const std::uint32_t sum = std::accumulate(valid.begin(), valid_end, std::uint32_t{0});
const std::uint32_t avg = count ? sum / static_cast<std::uint32_t>(count) : 0u;
// 3) MAP + COMPOSE: counts -> millivolts -> tenths of a degree Celsius
const std::int32_t temp_x10 = adc_to_temp(static_cast<std::uint16_t>(avg));
std::printf("valid samples : %zu\n", count);
std::printf("average counts: %u\n", static_cast<unsigned>(avg));
std::printf("temperature : %d.%d C\n", temp_x10 / 10, temp_x10 % 10); // fine for positive readings
return 0;
}
Expected output:
valid samples : 6
average counts: 1002
temperature : 30.7 C
What to notice:
| Aspect | Procedural C | Object-oriented C++ | Functional-style C++ |
| Where state lives | Globals and statics | Member variables inside objects | Passed in, returned out |
| Testing on a PC | Needs stubs for globals | Needs mocks or interfaces | Call the function directly |
| Interrupt and multi-context safety | Depends on discipline | Depends on locking members | Pure code is naturally reentrant |
| Best at | Register-level drivers, tiny footprints | Modeling devices and resources with lifecycles | Data transformation, decisions, protocols, math |
| Watch out for | Global coupling | Deep hierarchies, virtual dispatch cost | Hidden allocations, deep recursion |
The practical answer isn’t “either/or”. Model hardware resources as classes (constructors, destructors and RAII work well there) and write the logic that runs on their data as pure functions.
| Pitfall | Why it hurts on a microcontroller | Fix |
| std::function in hot paths or ISRs | Possible heap allocation, indirect call, throws when empty | Template parameter, function pointer or fixed-size delegate |
| Recursion instead of loops | No tail-call guarantee; small stacks overflow | Use loops and algorithms; recurse only with a proven depth limit, or at compile time |
| [&] in stored callbacks | The captured local dies, leaving a dangling reference and random crashes | Capture by value, or make sure the lambda dies first |
| Copying buffers “to stay immutable” | Wastes RAM and cycles | Pass const& or std::span |
| Templates and ranges everywhere | Code bloat | Check with your toolchain’s size tool and the linker map file |
| Judging performance at -O0 | Abstractions rely on inlining | Measure with optimization on |
| Assuming FP prevents data races | Pure functions don’t protect shared hardware state | Keep volatile, atomics and critical sections in the shell |
Functional programming in C++ isn’t about turning firmware into Haskell. It’s a handful of habits: keep functions honest about their inputs and outputs, prefer const, build behavior from small pieces, and let the compiler do work at build time. Start small. Turn one conversion function into a pure constexpr function, test it on your PC, and notice how much calmer that part of your codebase feels.
If you want guided, hands-on practice writing modern C++ and Embedded C on real microcontrollers, explore the embedded systems training programs at the Indian Institute of Embedded Systems (IIES) in Bangalore.
Functional programming in C++ is a coding style built on pure functions, immutable data and functions treated as values. You use lambdas, standard algorithms such as std::transform and std::accumulate, constexpr, std::optional and C++20 ranges to write logic that is easier to test and reason about, without leaving C++.
No, C++ is a multi-paradigm language. It supports functional style through lambdas, higher-order functions and constexpr, but it does not enforce purity or immutability. You choose how much functional style to use, which is why it fits firmware projects well.
Yes, when applied selectively. Pure functions are easy to unit-test on a PC and are naturally reentrant, and constexpr moves work to compile time. Avoid heap-allocating constructs such as std::function in hot paths, keep recursion bounded, and check the flash and RAM impact in your map file.
Not necessarily. With optimization enabled, lambdas and template-based algorithms usually inline to the same code as a hand-written loop. The real risks are std::function allocations, deep recursion and template code bloat, so measure with your toolchain’s size tool, for example arm-none-eabi-size, before and after.
A lambda is an unnamed function object the compiler generates, and its exact type is known at compile time. std::function is a type-erased wrapper that can hold any callable with a matching signature. Lambdas are usually faster and smaller, while std::function is more flexible but may allocate memory.
Indian Institute of Embedded Systems – IIES