C++ has evolved significantly since C++11, but a lot of embedded and systems code is still stuck in the C++03 era. Here are the modern C++ features (C++17/20) that have actually made it into my daily workflow - features that make code cleaner without runtime overhead.
Structured Bindings (C++17)
This is probably the feature I use most often. Structured bindings let you unpack tuples, pairs, and structs into named variables in a single declaration.
Before
std::map<int, std::string> device_map;
// ...
for (auto it = device_map.begin(); it != device_map.end(); ++it) {
int id = it->first;
std::string name = it->second;
// use id and name
}
After
std::map<int, std::string> device_map;
// ...
for (const auto& [id, name] : device_map) {
// use id and name directly
}
This also works great with functions that return multiple values:
auto [success, error_code] = initialize_device();
if (!success) {
log_error(error_code);
return;
}
std::optional (C++17)
No more magic sentinel values or out-parameters for "maybe has a value" situations.
std::optional makes the intent explicit in the type system.
Before
// Returns -1 if device not found (magic value)
int find_device_id(const std::string& name);
// Or using out-parameter
bool find_device_id(const std::string& name, int* out_id);
After
std::optional<int> find_device_id(const std::string& name);
// Usage
if (auto id = find_device_id("sensor_1")) {
configure_device(*id);
}
The beauty is that the API is self-documenting. You can't accidentally use an uninitialized value because the type forces you to check.
if constexpr (C++17)
Compile-time conditionals that actually remove code paths. Essential for template metaprogramming and writing code that adapts to different platforms.
template<typename T>
void process_data(T value) {
if constexpr (std::is_integral_v<T>) {
// Integer-specific processing
value &= 0xFF;
} else if constexpr (std::is_floating_point_v<T>) {
// Float-specific processing
value = std::clamp(value, 0.0, 1.0);
}
// Common processing...
}
Unlike regular if, the non-taken branch doesn't even need to compile for the
given type. No more SFINAE gymnastics for simple cases.
std::string_view (C++17)
A non-owning view into a string. Perfect for function parameters when you just need to read string data without copying.
// Before: forces copy if called with string literal
void log_message(const std::string& msg);
// After: zero-copy for literals and existing strings
void log_message(std::string_view msg);
I use this extensively in parsing code where I'm just examining substrings without modifying them. The performance difference is measurable on embedded systems.
constexpr Everywhere (C++17/20)
constexpr functions have become much more powerful. In C++20, you can even use
std::vector and std::string in constexpr contexts.
constexpr uint32_t crc32_table[256] = /* computed at compile time */;
constexpr uint32_t compute_crc32(std::string_view data) {
uint32_t crc = 0xFFFFFFFF;
for (char c : data) {
crc = crc32_table[(crc ^ c) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFF;
}
// CRC computed at compile time!
constexpr auto firmware_crc = compute_crc32("firmware_v1.0");
Moving computation from runtime to compile time is free performance, and the compiler verifies correctness.
Designated Initializers (C++20)
Finally, named initialization for structs like C99 has had for decades.
struct DeviceConfig {
uint32_t baud_rate;
uint8_t data_bits;
uint8_t stop_bits;
bool parity_enable;
};
// Clear, self-documenting initialization
DeviceConfig config = {
.baud_rate = 115200,
.data_bits = 8,
.stop_bits = 1,
.parity_enable = false
};
Much better than positional initialization where you're counting commas and hoping you got the order right.
std::span (C++20)
Like string_view but for arrays. A non-owning view into contiguous data.
// Works with arrays, vectors, or any contiguous container
void process_samples(std::span<const int16_t> samples) {
for (auto sample : samples) {
// process...
}
}
// All of these work:
int16_t raw_buffer[256];
std::vector<int16_t> vec_buffer;
std::array<int16_t, 128> arr_buffer;
process_samples(raw_buffer);
process_samples(vec_buffer);
process_samples(arr_buffer);
This is a game-changer for embedded code where you're constantly passing buffer pointers and sizes separately.
Range-based for with Init Statement (C++20)
A small quality-of-life improvement: you can now declare variables in the for loop that are scoped to the loop.
for (auto& registry = get_device_registry(); auto& [id, device] : registry) {
device.update();
}
What I Don't Use (Yet)
Not every new feature makes sense for embedded/systems work:
- Coroutines - Powerful but complex. Not worth it for most embedded use cases.
- Modules - Toolchain support is still spotty. Sticking with headers for now.
- Concepts - Useful for library code, overkill for application code.
- Ranges library - Elegant but compile times suffer. Maybe when compilers improve.
Compiler Support
All of the features I've listed work with:
- GCC 10+ (ARM and x86)
- Clang 12+
- MSVC 2019+ (for Windows tooling)
If your embedded toolchain is stuck on GCC 7 or older, it might be time for an upgrade. The productivity gains from modern C++ are worth the migration effort.
Conclusion
Modern C++ isn't about adding complexity - it's about expressing intent more clearly while maintaining zero-cost abstractions. The features I've highlighted all compile to efficient code while making the source more readable and less error-prone.
Start small. Pick one feature, use it for a week, and see how it feels. My recommendation:
start with structured bindings and std::optional. They're immediately useful
and require minimal mental overhead.