Date: Sat, 10 Jan 2026 09:09:26 -0500
I'm looking for feedback on this proposal. It is appended in markdown format.
Thanks.
Thanks.
---
title: "User-Defined Trivial Constructors"
document: Dxxxxr0
date: 2026-01-10
audience: EWG, LEWG
author:
- name: Jody Hagins
email: <coachhagins_at_[hidden]>
toc: true
---
# Abstract
This paper proposes allowing `= default` on user-defined constructors, enabling user-defined trivial constructors. This resolves a design tension where types cannot simultaneously provide safe default initialization AND qualify as implicit lifetime types. The primary motivation is `std::atomic`, which C++20 inadvertently rendered unusable in shared memory scenarios, but the solution benefits any type facing this trilemma.
# The Helpful Neighbor Problem
You know that neighbor. The one who "fixes" things.
You have a fenced in back yard, and your dog hangs out there while you are at work.
However, the latch is a bit low, and the dog has figured out how to get out on his own, because he's smart and he's a dog, not a cat.
The cat would just climb the tree next to the fence.
Helpful Neighbor, bless his heart, decides to solve this problem while you're at work. He welds the gate shut. Problem solved! The dog can't escape anymore.
Of course, now *you* can't get into your own backyard either. But hey, the dog's contained.
When you point this out, Helpful Neighbor offers solutions: you could climb over the fence (inconvenient), or you could install a new gate that only opens with a 47-step authentication process (safe but unusable), or you could just accept that the backyard is now dog-only territory (give up).
What you *actually* needed was a latch that works for humans but not dogs. A solution that achieves the safety goal without sacrificing basic functionality.
C++20 had a Helpful Neighbor moment with `std::atomic`.
# Motivation
## The Trilemma
Consider a type author who wants to create a type that:
1. **Has safe default initialization** — objects are never left uninitialized
2. **Is an implicit lifetime type** — can be used in shared memory, memory-mapped files, or with `std::start_lifetime_as`
3. **Cannot be copied or moved** — the type semantically represents a unique resource
Today, it's near impossible.
To be an implicit lifetime type, a class must, among other things, have at least one trivial constructor. The only way to explicitly specify a trivial constructor is via `= default`. But you can't only use `= default` user-defined constructors. Hence, no way for anything but the default, copy, and move constructors to be trivial.
If you delete copy and move (requirement 3), only the default constructor remains eligible to be trivial. But if you make the default constructor trivial (requirement 2), objects can be left uninitialized (violating requirement 1).
Pick any two. You can't have all three.
| Choice | What You Get | What You Lose |
|--------|--------------|---------------|
| Safe default ctor | Objects always initialized | Not an implicit lifetime type |
| Trivial default ctor | Implicit lifetime type | Uninitialized objects possible |
| No default ctor | No uninitialized objects | No `Foo x;` or `Foo x{};` syntax |
This isn't a theoretical concern. It's the exact situation `std::atomic` finds itself in after C++20.
## Real-World Impact
The implicit lifetime type requirement isn't academic. It's essential for:
- **Shared memory** — inter-process communication via memory-mapped regions
- **Memory-mapped files** — persistent data structures
- **Custom allocators** — placement new into raw storage
- **`std::start_lifetime_as`** — explicit lifetime management
Every C++ message queue library uses atomics in shared memory. Lock-free data structures in shared memory are a common pattern. All of this code is now, technically speaking, undefined behavior.
The standard even explicitly contemplates cross-process atomics. [atomics.lockfree] discusses lock-free atomics working correctly: "This restriction enables communication by memory that is mapped into a process more than once and by memory that is shared between two processes."
Except now, getting a `std::atomic` *into* shared memory with defined behavior is impossible.
"But it works!" you might say. And you'd be right — today. Compilers are getting smarter. Optimizers are getting more aggressive. Undefined behavior that "works" today may not work tomorrow. We've all seen this movie before.
# The `std::atomic` Story
## What Happened
In C++20, P0883R2 "Fixing Atomic Initialization" changed `std::atomic`'s default constructor. Previously, it was trivial (did nothing). After P0883, it explicitly zero-initializes.
The motivation was reasonable: users writing `std::atomic<int> counter;` would get an uninitialized atomic, which is almost certainly a bug. Making the default constructor zero-initialize catches this class of errors.
But, in that same C++20 cycle, P0593R6 "Implicit creation of objects for low-level object manipulation" formalized implicit lifetime types, including the part about trivial constructors.
The result? C++20 simultaneously:
1. Defined what implicit lifetime types are
2. Made `std::atomic` not be one
## The Constructor Situation
Let's look at `std::atomic`'s constructors prior to C++20:
| Constructor | Status | Trivial? |
|-------------|--------|----------|
| Default | = default | Yes, if T is trivial |
| Copy | Deleted | N/A |
| Move | Deleted | N/A |
| Value | Takes a T | No (user-defined) |
This type is an implicit lifetime type, according to the definition drafted for C++20.
And the is `std::atomic`'s constructors after C++20:
| Constructor | Status | Trivial? |
|-------------|--------|----------|
| Default | Zero-initializes | No |
| Copy | Deleted | N/A |
| Move | Deleted | N/A |
| Value | Takes a T | No (user-defined) |
Zero trivial constructors. Not an implicit lifetime type. No way to use it in shared memory with defined behavior.
The P0883 authors had good intentions. They saw a safety problem and fixed it. But like our Helpful Neighbor, they welded the gate shut in the process.
## Why Not Just Undo It?
We could revert P0883's change to the default constructor. But that brings back uninitialized atomics, which genuinely is a footgun.
We could make the default constructor private (and trivial).
This would satisfy implicit lifetime requirements because there is a trivial default constructor, even though it is private.
But, this would prevent accidentally creating an uninitialized instance since `std::atomic<int> x;` would no longer compile.
Unfortunately, neither would `std::atomic<int> x;{}`.
While a compile error is better than silent uninitialization, it's a breaking change, and it's still inconvenient.
What we need is a way to have *both*: a safe default constructor *and* a trivial constructor for implicit lifetime purposes. The language currently doesn't allow this.
# Proposed Solution
## User-Defined Trivial Constructors
We propose allowing `= default` on constructors that are not special member functions:
```cpp
struct Foo {
int value_;
// Safe: always initializes
Foo() : value_{0} {}
// Trivial: enables implicit lifetime
explicit Foo(std::trivial_t) = default;
};
```
The semantics are straightforward:
- `= default` on a user-defined constructor means "do nothing"
- It is trivial — exactly what `Foo() = default` would do
- The parameter exists only for overload resolution
- It is only valid if `Foo() = default` would produce a trivial default constructor
- It must be marked `explicit` to prevent unintended use and because `explicit` should be the default, but that's not a nice story about my dog and my neighbor.
## Usage
```cpp
// Normal usage — safe, always initialized
Foo regular;
Foo also_regular{};
// Explicit trivial construction — for shared memory scenarios
// where the object is constructed and manually initialized later.
Foo in_shared_memory(std::trivial);
```
In practice, the trivial constructor may never be called directly. Its existence is what matters — it makes the type an implicit lifetime type, enabling `std::start_lifetime_as`, `mmap`, and similar operations to implicitly begin the object's lifetime.
## Impact on `std::atomic`
With this feature, `std::atomic` can be fixed with zero breaking changes:
```cpp
template<typename T>
struct atomic {
// ... existing members ...
// Existing: safe zero-initializing default ctor
atomic() noexcept : value_{} {}
// New: trivial ctor for implicit lifetime
explicit atomic(std::trivial_t) = default;
};
```
Existing code continues to work exactly as before. The only change is that `std::atomic<int>` is now, once again, an implicit lifetime type.
# Alternative Solutions Considered
There are numerous options for addressing the `std::atomic` problem, as summarized below.
These are, it happens, the same options users have for their own types, many of which have been in use for decades.
For example, the Boost.Interprocess types all fall into this same category.
They are used within shared memory, and some require explicit construction.
They sit on top of native builtin types that could be implicitly created, but there is no explicit way to specify this.
## Committee Fiat
The committee could simply declare that `std::atomic<T>` is an implicit lifetime type for lock-free built-in types, rules be damned.
This works for `std::atomic` but sets a troubling precedent.
It doesn't help user-defined types facing the same trilemma.
And "we'll just special-case it" isn't a scalable design philosophy.
## New Atomic Type
We could introduce a new type, `std::basic_atomic` or similar, that maintains trivial constructors.
This splits the ecosystem. Library authors would need to decide which atomic to use. Existing code using `std::atomic` in shared memory remains broken. And again, it doesn't solve the general problem.
## `std::atomic_ref`
"Just use `std::atomic_ref` with raw integer types in shared memory."
This is viable, but:
- Existing code is still undefined behavior
- It requires discipline: *all* accesses must go through `atomic_ref`
- The standard explicitly states: "While any `atomic_ref` instances exist that reference the `*ptr` object, all accesses to that object shall exclusively occur through those `atomic_ref` instances"
- One direct access without the wrapper is undefined behavior
- This is easy to get wrong
## Private Trivial Default Constructor
```cpp
template<typename T>
struct atomic {
private:
atomic() = default; // Trivial, satisfies implicit lifetime
public:
// Users must explicitly initialize
atomic(T desired) noexcept : value_(desired) {}
};
```
This achieves implicit lifetime status and prevents uninitialized atomics (the old `std::atomic<int> x;` becomes a compile error). But it's a breaking change — existing code using `std::atomic<int> x{};` would fail.
Without a language change, this is the best we can get.
It's inconvenient for all, and requires lots of code change to adopt, but it works.
## Why User-Defined Trivial Constructors Win
The proposed solution:
- **Solves the general problem** — any type can benefit, not just `std::atomic`
- **Is non-breaking** — existing code continues to work
- **Is principled** — no special-case magic, just a natural extension of `= default`
- **Achieves both goals** — safe default initialization AND implicit lifetime eligibility
# Design Decisions
## Tag Type
The proposal uses a standard-provided tag type `std::trivial_t` (with a corresponding `std::trivial` value).
This ensures uniform usage across the ecosystem.
I'm not in love with the name, and would happily consider alternatives.
An alternative design would allow *any* constructor signature to use `= default`.
This provides maximum flexibility because we don't know what the future holds, but may be more than necessary.
The tag type approach is simpler to specify and makes intent clear.
We're open to either approach — this is an area where committee feedback would be valuable.
## Why `explicit`?
The trivial constructor should be marked `explicit` to prevent accidental use:
```cpp
void takes_foo(Foo);
takes_foo({}); // Uses default ctor — safe
takes_foo(std::trivial); // Compile error — explicit required
takes_foo(Foo(std::trivial)); // OK — intentional
```
This ensures developers don't accidentally create uninitialized objects through implicit conversions.
Not to mention, that `explicit` should be the default for constructors, and you must say `explicit(false)` to get an implicit one, but that doesn't fit with a cozy story about my dog and my neighbor.
## Constraint: Must Be Trivial
The feature is only valid when the equivalent `Foo() = default` would be trivial.
If a class has members with non-trivial default constructors, `Foo(std::trivial_t) = default` is ill-formed.
This prevents misuse — you can't create a "trivial" constructor that actually isn't.
# Implementation Experience
[TODO: Seek implementation experience from compiler vendors]
# Wording
[TODO: Detailed wording changes. Key areas:]
- Extend [class.default.ctor] or create new section for defaulted non-special-member constructors
- Define when such a constructor is trivial
- Add `std::trivial_t` to `<type_traits>` or a new header
- Specify `is_trivially_constructible` behavior
- Update `std::atomic` specification
- Others???
# Acknowledgments
[TODO]
# References
- P0883R2 "Fixing Atomic Initialization"
- P0593R6 "Implicit creation of objects for low-level object manipulation"
- [basic.life] Object lifetime
- [class.default.ctor] Default constructors
- [atomics.lockfree] Lock-free property
Received on 2026-01-10 14:09:40
