The Compatibility Landscape in 2026
In 2019, the article "C Constructs That Don't Work in C++" listed several incompatibilities. Since then, both C++20 and C23 have moved the border. The practical lesson: always specify the language mode. "Valid C" or "valid C++" is no longer precise enough. Use C17, C23, C++17, C++20, or C++23.
Designated Initializers: C++20 Added Them, But Not C's Version
C++20 introduced designated initializers for aggregate initialization. This works:
struct Address {
const char* street;
const char* city;
const char* state;
int zip;
};
Address white_house{
.street = "1600 Pennsylvania Avenue NW",
.city = "Washington",
.state = "District of Columbia",
.zip = 20500,
};
But C++ designators must name direct non-static data members in declaration order. Out-of-order is invalid:
struct Options {
int timeout_ms;
bool verbose = false;
int retries = 0;
};
Options o{
.retries = 3, // invalid C++20: out of declaration order
.timeout_ms = 5000,
};
C also permits array designators, nested designators, and mixing positional and designated clauses — all invalid in C++. This is intentional: C++ has constructors, destructors, default member initializers, and an order-of-initialization model that C-style freedom would collide with.
Empty Parameter Lists: C23 Moves Toward C++
In C++:
void fn();
fn(42); // invalid C++: fn takes no arguments
In C17 and earlier, void fn(); did not provide a prototype. Calls with mismatched arguments had undefined behavior. C23 changes this: a function declarator without a parameter type list now behaves as if it used void, providing a prototype and requiring argument count agreement. This improves compatibility but may break old C code compiled in C23 mode. Rule: in C-facing headers, void fn(void) remains the least surprising spelling.
void* and malloc: Lifetime Trap Partially Fixed
C lets you write:
int* values = malloc(100 * sizeof *values);
C++ requires a cast:
auto* values = static_cast(std::malloc(100 * sizeof(int)));
But the interesting part is object lifetime. C++20 narrowed the issue: some operations, including C allocation functions, implicitly create objects of implicit-lifetime types if doing so makes the program defined. For a trivial aggregate like struct X { int a; int b; };, the following is now valid in C++20:
X* make_x() {
auto* p = static_cast(std::malloc(sizeof(X)));
p->a = 1;
p->b = 2;
return p;
}
But this does not call constructors, initialize values, or work for non-implicit-lifetime types like std::string:
#include
#include
void bad() {
auto* s = static_cast(std::malloc(sizeof(std::string)));
*s = "hello"; // undefined behavior: no std::string object was constructed
}
The safe low-level C++ spelling is explicit placement new and destroy_at. The better high-level spelling is std::make_unique. Rule: a cast from void* is never the whole story. Ask five questions: who owns storage, when does lifetime begin, how is it initialized, who destroys it, and what happens on failure?
const_cast: Compiles Is Not the Same as Defined
C++ forces explicit const-discarding:
const int x = 100;
int* p = &x; // invalid C++
int* p = const_cast(&x); // compiles, but writing through p is UB
Valid use case: when the original object is not const. Rule: const_cast is a localized escape hatch, not a permission slip. Don't use for string literals or read-only storage.
Enums: C23 Adds Fixed Underlying Types
C23 makes the underlying type model more explicit. In C++, an enumeration is a distinct type. Unscoped enums can implicitly convert to int, but int to enum is not implicit. Scoped enums don't convert at all. Rule: use enum class for C++ APIs, plain enums only for C interop.
Flexible Array Members Still Not in C++
C99's trailing-array pattern remains non-standard in C++. Keep the C layout at the ABI edge; translate into span, vector, or an explicit header/payload representation.
What Changed Since 2019
- C++20: Designated initializers (constrained), object lifetime repair for implicit-lifetime types.
- C23: Empty parameter lists now mean
void, enums gain fixed underlying types. - Unchanged:
void*conversion, const-discarding, flexible array members,restrict.
Practical Advice
- Label language modes explicitly (C17, C23, C++20, etc.) when discussing compatibility.
- For shared headers, use
void fn(void)to avoid surprises. - Prefer
enum classfor new C++ code. - Don't use
mallocin C++; usenewormake_unique. - Use C++20 designated initializers only for plain aggregates, in declaration order, all-designated.
The companion repository at GitHub provides repeatable checks and Compiler Explorer links for quick diagnostics.


