LmCast :: Stay tuned in

C constructs that still don't work in C++

Recorded: May 25, 2026, 4:58 a.m.

Original Summarized

C Constructs That Still Don’t Work in C++ — and a Few That Changed | Josh Lospinoso Skip to content lospino.so Blog Projects Talks Media Publications Search C++ | May 20, 2026 C Constructs That Still Don’t Work in C++ — and a Few That Changed A 2026 sequel to C Constructs That Don't Work in C++: what still breaks, what C++20 changed, and what C23 changed. GitHubJLospinoso/c-constructs-still-dont-work-cpp In 2019 I wrote a short survey of C constructs that do not work in
C++.
The point was not that C is sloppy or that C++ is superior. The point was that
C++ is not a superset of C, and that C programmers crossing the border should
know where the checkpoints are.
That advice still holds. But the border moved.
C++20 picked up a version of designated initializers. C++20 also repaired some
low-level object-lifetime cases around malloc that used to be easy to
describe incorrectly. C23, meanwhile, changed the old empty-parameter-list rule
that made void f() mean something dangerously different in C than in C++.
The practical lesson is the same, but sharper: when you discuss C/C++
compatibility, label the language mode. “Valid C” and “valid C++” are not
precise enough anymore. You often need to say C17, C23, C++17, C++20, or
C++23.
I also put the examples behind this post in a small
companion repository.
The repository is for repeatable checks; its
Compiler Explorer links
are for quick diagnostics.
Compatibility matrix
Here is the short map. Details follow. The point is not just that C++ is not a
superset of C. The point is that some of the canonical examples people still
repeat changed under C++20 or C23, so the correct answer now depends on the
language mode. This table is a map, not a substitute for the examples; on
narrow screens, the sections below are easier to read than the full matrix.
StatusConstructC17C23C++17C++20 / C++23Practical adviceStill differentvoid* to object pointerImplicit conversion from malloc is idiomatic C.Same.No implicit conversion.Same.In C++, do not make malloc your default allocation strategy. If you must use it, cast deliberately and handle lifetime deliberately.Changed since 2019malloc and object lifetimeC allocation creates storage used as objects by C’s rules.Same broad C model.Easy to write code that compiles with a cast but has no C++ object lifetime.Some implicit-lifetime cases are repaired. Constructors still are not called.Distinguish storage, lifetime, initialization, ownership, and destruction.Still differentDiscarding constConstraint violation; compilers often warn.Same basic concern.Ill-formed without a cast.Same.A cast may compile. It does not make writes to actually-const objects defined.Changed in C23EnumsEnumerator constants are integer-like; enum objects convert freely enough to surprise C++ programmers.C23 adds fixed underlying types and more explicit typing rules for enumerators.Enum types are distinct; int to enum is not implicit.Same; enum class remains stricter.Use enum class for C++ APIs. Use plain enums only when ABI or C interop demands it.Changed in C23void f()No prototype in the old sense; mismatched calls may compile, but are not defined.Behaves as though declared with void.Means no parameters.Same.For shared headers, still write void f(void) in C-facing APIs unless you control the language mode.Changed since 2019Designated initializersFull C-style designated initialization, including out-of-order, array, nested, and mixed forms.Same family, with C23 evolution elsewhere.Not standard C++.Standard, but narrower than C.Useful in C++20, but only for aggregates, direct members, declaration order, and all-designated clauses.Extension traprestrictStandard C99 qualifier.Still standard C, with C23 wording updates.Not standard C++.Not standard C++.Use compiler extensions only behind a portability boundary.Still differentFlexible array membersStandard C99 trailing-array pattern.Still standard C.Not standard C++.Not standard C++.Keep the C layout at the ABI edge; translate into span, vector, or an explicit header/payload representation.
Designated initializers: yes, but not C’s version
The 2019 post said designated initializers were not available in C++, with a
note that they were likely coming in C++20. That note aged well.
C++20 added designated initializers for aggregate initialization. This is valid
C++20:
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,
};
This is not the same feature C programmers are used to.
C++ designators must name direct non-static data members, and they must appear
in declaration order. That means this out-of-order form remains invalid C++:
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 patterns that C++ still rejects, including array designators and
nested designators:
int table[4] = { [2] = 99 }; // valid C, invalid C++

struct Inner { int value; };
struct Outer { struct Inner inner; };
struct Outer o = { .inner.value = 7 }; // valid C, invalid C++
C also lets you mix positional and designated clauses in the same initializer:
struct Triple {
int first;
int second;
int third;
};

struct Triple t = { 1, .third = 3 }; // valid C, invalid C++
In C++20, the analogous aggregate initialization is ill-formed:
struct Triple {
int first;
int second;
int third;
};

Triple t{1, .third = 3}; // invalid C++20: mixed designated and positional clauses
This is not arbitrary. C++ has constructors, destructors, default member
initializers, references, and an order-of-initialization model that code can
observe. C-style freedom would collide with the C++ object model.
There are proposals to loosen the C++ rules, including out-of-order designated
initializers and base-class designated initialization. Treat those as proposals.
Do not write portable C++ on the assumption that they have landed.
Rule: C++20 designated initializers are great for plain aggregate configuration
objects. They are not a drop-in replacement for C99 designated initialization.
C++20’s form is not “C designators, now in C++.” It is a constrained
aggregate-initialization feature. The useful mental model is: direct members,
declaration order, and do not mix designated and non-designated clauses.
Empty parameter lists: C moved toward C++
This used to be one of the cleanest examples of C and C++ disagreement.
In C++:
void fn();

fn(42); // invalid C++: fn takes no arguments
In C17 and earlier, void fn(); did not provide a prototype. A definition
written void fn() {} specified no parameters, but calls made through a
non-prototype declaration were not checked the way C++ programmers expect. Such
a call might compile after default argument promotions, but if the number of
supplied arguments does not match the number of parameters, the behavior is
undefined:
void fn() { }

int main(void) {
fn(42); // may compile in C17 mode; undefined for this definition
}
C23 removes the old split: a function declarator without a parameter type list
behaves as if it used void, provides a prototype, and the argument count must
agree.
That is a real compatibility improvement. It also creates a migration wrinkle:
older C code may compile in C17 mode and fail in C23 mode. That is good failure,
but it is still failure.
Rule: in C-facing headers, void fn(void) remains the least surprising
spelling. In C++-only code, void fn() is fine.
void*, malloc, and the object-lifetime trap
The simple incompatibility is unchanged. C lets you write this:
int* values = malloc(100 * sizeof *values);
C++ does not implicitly convert void* to int*:
int* values = std::malloc(100 * sizeof *values); // invalid C++
You can cast:
auto* values = static_cast<int*>(std::malloc(100 * sizeof(int)));
But the cast is not the interesting part. The interesting part is lifetime.
In current C++, the sharp edge is not “malloc can never give you objects.”
C++20 narrowed that. Some operations, including C allocation functions, are
specified to implicitly create objects of implicit-lifetime types if doing so
would make the program defined; the draft’s example is essentially a trivial
aggregate returned by std::malloc and then assigned through its members. That
repair is deliberately limited: it does not run constructors, initialize scalar
values, establish invariants, or start lifetimes for subobjects that are not
themselves implicit-lifetime types. For non-implicit-lifetime types, storage is
still just storage until construction happens.
So this kind of code is no longer the best scare example in C++20:
#include <cstdlib>

struct X {
int a;
int b;
};

X* make_x() {
auto* p = static_cast<X*>(std::malloc(sizeof(X)));
p->a = 1;
p->b = 2;
return p;
}
For an implicit-lifetime type like X, C++20 repairs the lifetime issue. That
does not make malloc idiomatic C++.
The repair does not call constructors. It does not initialize values. It does
not give you exception safety. It does not pair ownership with destruction. It
does not make this OK:
#include <cstdlib>
#include <string>

void bad() {
auto* s = static_cast<std::string*>(std::malloc(sizeof(std::string)));
*s = "hello"; // undefined behavior: no std::string object was constructed
}
The safe low-level C++ spelling is explicit:
#include <memory>
#include <new>
#include <string>

void* storage = ::operator new(sizeof(std::string));
auto* s = new (storage) std::string("hello");

std::destroy_at(s);
::operator delete(storage);
The better high-level spelling is usually simpler:
auto s = std::make_unique<std::string>("hello");
Rule: a cast from void* is never the whole story. Ask five questions: who owns
the storage, when does the object lifetime begin, how is the object initialized,
who destroys it, and what happens on failure?
const_cast: compiles is not the same as defined
The old post pointed out that C++ forces you to be explicit when discarding
const:
const int x = 100;
int* p = &x; // invalid C++
You can write the cast:
const int x = 100;
int* p = const_cast<int*>(&x);
But this only removes the type-system barrier. It does not change the object.
Writing through p is undefined behavior because x is actually a const
object.
There is a valid use case:
int x = 100;
const int* view = &x;
int* p = const_cast<int*>(view);
*p = 101; // defined: the original object is not const
That distinction matters in legacy integration. Sometimes a C API takes char*
even though it promises not to mutate the buffer. A const_cast at that
boundary can be the least-bad option. Put it at the edge, document it, and keep
it out of the core logic.
Do not use this trick for string literals, memory-mapped read-only storage, or
objects originally declared const. If the legacy function actually writes,
the cast only moves the bug.
Rule: const_cast is not a permission slip. It is a localized escape hatch.
Enums: less simple than “C uses int”
The old shorthand “C enum values are backed by int” is too compressed for a
2026 version of this article. For C17, the safer shorthand is: enumerator
constants have integer type, while the enumerated type itself is compatible with
an implementation-defined integer type capable of representing its values. C23
makes the model more explicit: every enumeration has an underlying type, a
fixed underlying type can be written, and the type of an enumeration constant
after completion depends on whether the enumeration has a fixed underlying type
and whether the values fit in int.
That is still not C++‘s model. In C++, an enumeration is a distinct type. An
unscoped enumerator, or an object of unscoped enumeration type, can participate
in integral promotion or conversion, but an arbitrary integer is not assignable
to the enum without a cast. A scoped enum does not implicitly convert to int
or bool.
enum Mode { off = 0, on = 1 };

int x = on; // OK: unscoped enum to int
Mode m = 1; // invalid C++: int to Mode is not implicit
If you need to cross from an integer representation, say so:
Mode m = static_cast<Mode>(1);
For C++ APIs, prefer scoped enums:
enum class Mode : unsigned {
off = 0,
on = 1,
};

int x = Mode::on; // invalid C++
auto y = static_cast<unsigned>(Mode::on); // explicit
Rule: use plain enums when they are part of a C ABI or when you intentionally
want old enum behavior. Use enum class when the enum is a domain type in C++.
restrict: a C promise, not a C++ contract
C99 introduced restrict so a programmer could promise that a pointer is the
unique access path to an object for a period of execution. That promise can
unlock useful aliasing optimizations. If the promise is false, the behavior is
undefined.
Standard C++ has no restrict keyword. GCC and Clang support __restrict__
and __restrict as extensions. MSVC has __restrict for variables and
__declspec(restrict) for function declarations and definitions, with
return-value aliasing semantics. Treat all of these as toolchain contracts, not
portable C++ interface design.
Rule: if you need restrict-like semantics in C++, isolate the extension in a
small boundary, test it with the compilers you actually ship, and make the
aliasing precondition impossible to miss.
Flexible array members: keep them at the edge
C99 also standardized flexible array members:
struct Packet {
unsigned length;
unsigned char payload[];
};
This is a good C pattern for a variable-length object with a fixed header and
trailing data. It is not standard C++.
Some C++ compilers accept flexible array members as extensions. That does not
make the code portable C++. It also does not solve the lifetime and ownership
questions that C++ is trying to force into the open.
In C++, usually choose one of these instead:
struct Packet {
unsigned length;
std::vector<std::byte> payload;
};
or, when the storage is owned elsewhere:
struct PacketView {
unsigned length;
std::span<const std::byte> payload;
};
At an ABI boundary, you may need to preserve the C representation. That is
fine. But keep it quarantined. Parse the C layout, validate lengths, then
translate into a C++ representation with explicit ownership or a bounded view.
Rule: flexible array members are a C layout tool. They are not a portable C++
object model.
Migration rules
When moving C habits into C++, I use these rules:

Label the language mode before making the claim.
Do not assume “works in C” means “is C++ with warnings.”
Do not confuse “compiles with a cast” with “has defined behavior.”
Treat malloc as storage, not construction.
Preserve C layouts at ABI boundaries, then translate into C++ types.
Prefer C++ constructs that make ownership and lifetime visible.
Use compiler extensions only behind named, tested portability boundaries.

The old lesson still stands: C++ is not a superset of C. The updated lesson is
more precise: the languages increasingly share syntax, but they do not share the
same object model, initialization model, or invariants.
That is where the bugs hide.
References

Original post: C Constructs That Don’t Work in C++
Companion examples: C Constructs That Still Don’t Work in C++ repository
C23 working draft: WG14 N3220
C++ aggregate initialization and designated initializers: C++ draft, dcl.init.aggr
C++ C allocation functions: C++ draft, c.malloc
C++ object model and implicit object creation: C++ draft, intro.object
C++ object lifetime: C++ draft, basic.life
C++ cv-qualification and const modification: C++ draft, dcl.type.cv
C++ const_cast: C++ draft, expr.const.cast
C++ enumeration declarations: C++ draft, dcl.enum
C17-era no-prototype call behavior: WG14 N1570, 6.5.2.2 Function calls
C23 empty parameter list proposal: WG14 N2841, No function declarators without prototypes
Implicit object creation proposal: WG21 P0593R6
C++ designated initialization proposal: WG21 P0329R4
GCC C++ restricted pointer extension: GCC docs, Restricted Pointers
MSVC __restrict: Microsoft Learn, __restrict
Out-of-order designated initializer proposal: WG21 P3405R0
Base-class designated initializer proposal: WG21 P2287R5
Secondary reference, C enumerations: cppreference, C enum
Secondary reference, C restrict: cppreference, restrict type qualifier
Secondary reference, flexible array members: cppreference, struct declaration
© 2026 Josh Lospinoso GitHub LinkedIn Feed

The author Josh Lospinoso examines the ongoing compatibility issues between C and C++, arguing that the lack of a simple superset relationship is crucial for C programmers crossing the boundary between these languages. The central thesis is that incompatibility arises not from sloppy coding but from fundamentally different object models, initialization rules, and lifetime concepts between the two languages. To accurately discuss compatibility, Lospinoso advocates for explicitly labeling the language mode, such as C17, C++20, or C23, as simple labels like "valid C" or "valid C++" are insufficient.

The text details specific areas where C constructs diverge from C++ object orientation. For instance, C's handling of memory allocation via malloc creates storage that operates under C's rules, which conflicts with C++'s emphasis on object lifetime, ownership, and destruction. While C allows implicit conversions, C++ imposes stricter rules regarding implicit object creation and lifetime management, necessitating a focus on distinguishing storage, initialization, and destruction. Lospinoso stresses that the danger in these areas lies not just in compilation errors but in undefined behavior arising from mismatched assumptions about object existence.

Designated initializers, a feature introduced in C++20 for aggregate initialization, are presented as an example of this divergence. C allows flexible initialization patterns, including out-of-order or nested designators, which are generally invalid in C++. C++ designates initialization is constrained by the C++ object model, requiring direct member naming in declaration order and prohibiting mixing designated and positional clauses, as the C++ object model incorporates constructors and other initialization mechanisms. Lospinoso advises treating C++20 designated initializers as a constrained aggregate feature rather than a direct replacement for C99 style initialization.

The handling of empty parameter lists also reveals an essential difference. While C does not define function prototypes, C++ treats `void fn()` differently, leading to potential mismatches in argument counting that may compile in C17 mode but result in undefined behavior or failures in C23 modes. This highlights that the meaning of function declarations is context-dependent based on the language mode.

Furthermore, the interaction between void pointers, malloc, and object lifetime is critical. Although C allows casting void pointers from malloc results implicitly, C++ enforces stricter rules regarding object construction and destruction, meaning the mere ability to cast does not resolve lifetime issues. The repair made in C++20 regarding implicit object creation in low-level functions does not invoke constructors or establish ownership invariants, emphasizing that high-level safety requires explicit management, such as using smart pointers.

The use of const_cast is also analyzed as an escape hatch rather than a permission slip. While it allows bypassing type system barriers, modifying a memory location through const_cast results in undefined behavior relative to the object's actual constness. Lospinoso recommends using it sparingly, primarily at boundaries with legacy C APIs where such interaction is unavoidable, emphasizing that it should not be used for string literals or read-only memory.

Regarding enumerations, the shorthand C practice where enum values are assumed to be integers is contrasted with the C++ model where enumerations are distinct types. C++ allows scoped enumerations which enforce stricter type separation, preventing implicit conversions to integer types or bools, thereby forcing explicit casts when interacting with the underlying integer representation. Lospinoso recommends using scoped enumerations for C++ APIs, reserving plain enumerations only when interfacing with a C Application Binary Interface or when intentionally mimicking old C behavior.

The concept of restrict, introduced in C99, is framed as a promise that C++ lacks. Since C++ does not have a direct equivalent, Lospinoso suggests treating compiler extensions like __restrict as toolchain contracts rather than portable C++ guarantees. For features like flexible array members, which are C layout tools, the advice is to quarantine them at ABI boundaries and translate them into standard C++ equivalents like std::vector or std::span, ensuring that C++ object modeling principles regarding ownership and lifetime are maintained.

In summary, the overarching rule for migration is to prioritize clarity over assumed compatibility. C++ is not a superset of C, and the correct approach involves recognizing that differences in object model, initialization, and lifetime are the source of bugs. This requires a disciplined approach where C habits are translated into explicit C++ constructs that make ownership, state, and object lifetimes transparent, reserving compiler extensions for clearly demarcated, tested boundaries.