Back to Blog

Modern C++ Features I Actually Use

September 2024 6 min read
Code on screen

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:

Compiler Support

All of the features I've listed work with:

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.