Modern C++ for Embedded Development — Designated Initializers
Introduction
When writing embedded code, have you ever been frustrated by obscure struct initializations like this?
UART_InitTypeDef uart;
uart.BaudRate = 115200;
uart.WordLength = UART_WORDLENGTH_8B;
uart.StopBits = UART_STOPBITS_1;
uart.Parity = UART_PARITY_NONE;
// Or even worse, the positional initialization nightmare:
TIM_TimeBaseInitTypeDef timer = { 0, 999, 0, TIM_COUNTERMODE_UP, 0 };The biggest problem with this code is that you must remember the declaration order of the struct members. If the struct definition changes (for example, inserting a new member in the middle), all initialization code might break. Worse still, the compiler won't report an error, and strange behaviors only manifest at runtime.
C99 introduced designated initializers, and C++20 officially incorporated them into the standard to solve this problem—allowing us to initialize members by name. This makes code clearer, safer, and easier to maintain.
TL;DR: Designated initializers allow initializing struct members by name using the
.member = valuesyntax, creating self-documenting code that is independent of declaration order.
However, using designated initializers in embedded development requires understanding their mechanics and limitations because:
- The syntax differs slightly from C (C++ uses braces
{}). - They can only be used for aggregate types, not classes with constructors.
- The default behavior of partial initialization needs to be clearly understood.
- Support varies across different compilers.
Let's walk through the correct usage of this feature step by step.
Basic Syntax
Simple Designated Initialization
C++20 designated initializers use the .member{value} syntax inside braces:
struct Point {
int x;
int y;
int z;
};
// Traditional initialization (order-dependent)
Point p1 = { 10, 20, 30 }; // x=10, y=20, z=30
// Designated initialization (order-independent)
Point p2 = { .z{30}, .x{10}, .y{20} }; // x=10, y=20, z=30The advantage of the second approach is obvious:
- Self-documenting code: Each value explicitly labels its corresponding field.
- Order-independent: Does not rely on the struct declaration order.
- Easy to maintain: Initialization code remains correct even if the struct definition changes.
Differences from C
The syntax for designated initializers in C is slightly different:
// C99 style (uses =)
Point p2 = { .z = 30, .x = 10, .y = 20 };Good news: C++20 adopted the same syntax as C99, allowing for better interoperability between the two languages.
Note: Before C++20, some compilers (like GCC, Clang) supported designated initializers as an extension, but the behavior might differ slightly from the C++20 standard.
Aggregate Type Requirements
Designated initializers can only be used with aggregate types. So, what is an aggregate type?
Definition of an Aggregate Type
In C++20, an aggregate type is a class type that satisfies the following conditions:
- No user-declared constructors.
- No private or protected non-static data members.
- No virtual functions.
- No virtual base classes.
- No default member initializers (prior to C++14).
// This is an aggregate
struct SensorConfig {
int pin;
int threshold;
bool enabled;
};
// This is NOT an aggregate (has user-declared constructor)
struct SensorConfig {
int pin;
SensorConfig(int p) : pin(p) {} // Not an aggregate
};Arrays Are Also Aggregate Types
Arrays can also use designated initializers:
int arr[5] = { [3] = 10, [1] = 20 }; // C style, mostly C-compatible
// Note: C++ designated initializers for arrays have limited supportNote: Support for array designated initializer syntax [index] = varies in C++; verify compiler support before use.
Embedded Scenarios in Practice
Scenario 1: UART Configuration Initialization
struct UARTConfig {
uint32_t baud_rate;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
bool flow_control;
};
void init_uart() {
// Clear and safe
UARTConfig cfg = {
.baud_rate = 115200,
.data_bits = 8,
.stop_bits = 1,
.parity = 0, // None
.flow_control = false
};
// Apply configuration...
}Scenario 2: GPIO Configuration
struct GPIOConfig {
GPIO_Port port;
uint16_t pin;
GPIO_Mode mode;
GPIO_Pull pull;
GPIO_Speed speed;
};
GPIOConfig led_config = {
.port = GPIOA,
.pin = 5,
.mode = GPIO_MODE_OUTPUT_PP,
.pull = GPIO_NOPULL,
.speed = GPIO_SPEED_FREQ_LOW
};Scenario 3: SPI Configuration
struct SPIConfig {
SPI_HandleTypeDef handle;
uint32_t mode;
uint32_t baud_prescaler;
uint32_t bit_order;
};
SPIConfig spi_flash = {
.mode = SPI_MODE_MASTER,
.baud_prescaler = SPI_BAUDRATEPRESCALER_4,
.bit_order = SPI_FIRSTBIT_MSB
// handle left default-initialized
};Scenario 4: Timer Configuration
struct TimerConfig {
uint32_t prescaler;
uint32_t period;
uint32_t clock_division;
uint32_t counter_mode;
};
TimerConfig pwm_timer = {
.prescaler = 71, // 1MHz tick
.period = 999, // 1kHz PWM
.counter_mode = TIM_COUNTERMODE_UP
};Scenario 5: Register Map Table
struct RegisterMap {
volatile uint32_t ctrl;
volatile uint32_t status;
volatile uint32_t data;
volatile uint32_t reserved[4];
};
// Memory-mapped IO initialization
const RegisterMap peripheral_base = {
.ctrl = 0x00,
.status = 0x00,
.data = 0x00
};Scenario 6: Message Packet Construction
struct Packet {
uint8_t start_byte;
uint8_t cmd;
uint16_t length;
uint8_t payload[256];
uint16_t checksum;
};
Packet cmd_packet = {
.start_byte = 0xAA,
.cmd = 0x01,
.length = 4,
.payload = { 0x01, 0x02, 0x03, 0x04 },
.checksum = 0x1234
};Partial Initialization and Default Values
Behavior of Partial Initialization
When using designated initializers, unspecified members follow these rules:
- If there is a default member initializer, use that default value.
- Otherwise, for aggregate types, perform value initialization (zero-initialization).
struct Device {
int id = 1; // Default member initializer
int status; // No default
int priority = 10; // Default member initializer
};
Device dev = { .id{5} };
// Result: id=5, status=0 (zero-initialized), priority=10 (default initializer)Beware of Implicit Zero Initialization
struct Buffer {
uint8_t* data;
size_t size;
bool is_ready;
};
Buffer buf = { .data{nullptr} };
// Result: data=nullptr, size=0, is_ready=falseIn embedded development, this implicit zero-initialization can lead to hard-to-find bugs. It is recommended to always explicitly initialize all important members.
Nested Structs and Arrays
Initializing Nested Structs
struct Inner {
int x;
int y;
};
struct Outer {
int a;
Inner inner;
int b;
};
Outer out = {
.a{10},
.inner{ .x{1}, .y{2} },
.b{20}
};Initializing Array Members
struct ArrayHolder {
int values[5];
int count;
};
ArrayHolder holder = {
.values{ [0]{1}, [4]{5} }, // Note: Array designated init support varies
.count{2}
};Note: Support for array designated initializer syntax [index] in C++20 may vary by compiler; verify before use.
Interaction with Constructors
Aggregate Types Cannot Have User-Defined Constructors
struct Bad {
int x;
Bad() = default; // User-declared constructor -> Not an aggregate
};
// Bad b = { .x{1} }; // Error: Not an aggregateIf you need to support both constructors and designated initializers, consider the following approaches:
Solution 1: Use Static Factory Methods
struct Config {
int baud;
int mode;
static Config create(int b) {
return { .baud{b}, .mode{0} };
}
};
Config cfg = Config::create(115200);Solution 2: Use Aggregate Initialization + Helper Functions
struct Config {
int baud;
int mode;
};
Config make_default_config() {
return { .baud{9600}, .mode{1} };
}Common Pitfalls and Limitations
Pitfall 1: Order-Dependent Initialization
struct Data {
int a;
int b;
};
Data d = { .b{2}, .a{1} }; // Valid, but confusingWhile the syntax allows out-of-order initialization, for readability, it is recommended to keep the order consistent with the struct declaration.
Pitfall 2: Impact of Member Reordering
struct V1 {
int x;
int y;
};
struct V2 {
int y; // Reordered
int x;
};
V2 v = { .x{1}, .y{2} }; // Safe! Order independentPitfall 3: Bit Field Members
struct Flags {
unsigned int flag1 : 1;
unsigned int flag2 : 1;
};
Flags f = { .flag1{1}, .flag2{0} }; // SupportedPitfall 4: Designated Initialization for Unions
union Data {
int i;
float f;
};
Data d = { .i{42} }; // OK
// Data d2 = { .i{42}, .f{3.14f} }; // Error: Only one member can be initializedPitfall 5: Precedence of Non-Static Member Initializers
struct S {
int x = 10;
};
S s = { .x{20} }; // x is 20, the explicit value overrides the defaultExplicitly specified values in designated initializers override default member initializers.
Limitation 1: Cannot Be Used on Non-Aggregate Types
class NonAggregate {
private:
int x;
public:
NonAggregate(int v) : x(v) {}
};
// NonAggregate n = { .x{10} }; // Error: Not an aggregateLimitation 2: Cannot Specify the Same Member Multiple Times
struct Point { int x; int y; };
// Point p = { .x{1}, .x{2} }; // Error: Duplicate member initializationLimitation 3: Cannot Skip Members in Some Compilers
While the C++20 standard allows partial initialization, some compilers may have additional restrictions or warnings in practice.
Limitation 4: Interaction with Base Classes
struct Base { int x; };
struct Derived : Base { int y; };
// Derived d = { .x{1}, .y{2} }; // Error: Cannot designate base class members directly
Derived d = { .y{2} }; // OK, x is zero-initializedC++20 Updates
C++20 officially incorporated designated initializers into the standard. Key features include:
- Standardized Syntax:
.member{value}becomes standard syntax. - Updated Aggregate Definition: Relaxed the definition of aggregate types.
- Interaction with Templates: Can be used in templates.
Usage in Templates
template<typename T>
struct Container {
T value;
int id;
};
Container<float> c = { .value{3.14f}, .id{1} };constexpr Context
struct Point {
int x;
int y;
};
constexpr Point origin = { .x{0}, .y{0} };
static_assert(origin.x == 0);Compiler Support
| Compiler | Support as Extension | C++20 Standard Support |
|---|---|---|
| GCC | 4.x+ | GCC 8+ |
| Clang | 3.x+ | Clang 10+ |
| MSVC | Not Supported | VS 2019 16.8+ |
When writing portable code, it is recommended to:
#if __cplusplus >= 202002L
Point p = { .x{1}, .y{2} };
#else
Point p = { 1, 2 }; // Fallback
#endifSummary
Designated initializers offer a concise and safe way to initialize objects in modern C++:
Comparison with Traditional Initialization:
| Feature | Traditional Initialization | Designated Initializers |
|---|---|---|
| Order Dependency | Yes | No |
| Code Readability | Poor (need to check definition) | Good (self-documenting) |
| Maintainability | Poor (struct changes require updates) | Good (immune to struct changes) |
| Partial Initialization | Supported (positional) | Supported (by name) |
Practical Recommendations:
Prefer in these scenarios:
- Configuration struct initialization.
- Register map tables.
- Hardware configuration constants.
- Message packet construction.
Use with caution in these scenarios:
- Initialization requiring validation logic (consider factory functions).
- Complex initialization order dependencies.
- Projects needing to support older compilers.
Embedded specific focus:
- Understand the default behavior of partial initialization.
- Be aware of bugs introduced by zero-initialization.
- Verify compiler support.
- Keep order consistent with struct declaration for readability.
Performance considerations:
- Designated initializers are a compile-time feature with no runtime overhead.
- Generates the same machine code as traditional aggregate initialization.
- Safe to use in performance-critical code.
Designated initializers bring C++ configuration code closer to a declarative programming style. Combined with constexpr, we can accomplish significant configuration work at compile time, making it an essential tool for modern C++ embedded development. Along with previously learned features like auto, structured binding, and attributes, we can write embedded C++ code that is both efficient and easy to maintain.