When a default-initializable type actually isn't
2025-01-18
I was looking at a proposal for adding value-semantic dynamically-allocated
types to C++. You can find it
here. This proposal
adds two class templates, indirect
and polymorphic
.
The primary use case for this is to make it easier to write correct composite classes. A motivating example can be found through existential types.
Suppose you want a type T which owns (ownership is key) some objects implementing some interfaces. You don't know what concrete types the sub-objects have, and you don't care. You're fine with dynamic dispatch, with the overhead that entails at runtime. You want value semantics, meaning (among other things) copying T copies the sub-objects too.
In Rust, this is expressible through Box<dyn T>
, which lets us own objects
and perform dynamic dispatch with value semantics. This is what polymorphic
will allow in C++, allowing you to avoid hacking around unique_ptr
. See the
proposal above for more detailed motivation.
Note that polymorphic<T>
isn't nullable, you can't construct one without an
associated T
. Suppose we have an abstract class:
struct MyInterface {
virtual void do_something() = 0;
};
Now here's what this blog post is about. Should the following compile?
static_assert(std::default_initializable<polymorphic<MyInterface>>);
The answer may surprise you! Or not, if you've read the title of the post.
What's the answer?
It does compile! Here's a complete example:
#include <https://raw.githubusercontent.com/jbcoe/value_types/refs/heads/main/polymorphic.h>
#include <concepts>
struct MyInterface {
virtual void do_something() = 0;
};
static_assert(std::default_initializable<xyz::polymorphic<MyInterface>>);
int main() {
//xyz::polymorphic<MyInterface> y{}; // uncomment this to see that it doesn't work!
return 0;
}
You can plug that into godbolt.org.
What? Why? That's insane!
Maybe. Let's check cppreference: https://en.cppreference.com/w/cpp/concepts/default_initializable.
The
default_initializable
concept checks whether variables of typeT
can be
- value-initialized (i.e., whether
T()
is well-formed);- direct-list-initialized from an empty initializer list (i.e., whether
T{}
is well-formed); and- default-initialized (i.e., whether
T t;
is well-formed).
Note that polymorphic<MyInterface>
satisfies exactly ZERO of these, but the
concept is somehow still satisfied.
There are two parts to this: what's the motivation for allowing this, and how does it actually work?
Motivation
polymorphic
and indirect
are designed to work with incomplete types - types
that haven't been defined, for example in a forward declaration.
If polymorphic<T>
had a single argument constructor that actually required
the default_initializable
concept for T
, then T
would need to be
complete - those kinds of checks always fail for incomplete T
.
This is very annoying - it means that to add support for incomplete types, you end up with a lot of misleading constraints being satisfied.
How does it work?
The no argument constructor for polymorphic<T>
doesn't actually require
default_initializable<T>
, but instead looks like
this:
explicit constexpr polymorphic()
requires std::default_initializable<A>
: polymorphic(std::allocator_arg_t{}, A{}) {
static_assert(std::default_initializable<T> && std::copy_constructible<T>);
}
Ignoring the bit about A
which is to do with allocators, note that the
constraints on T
are in a static_assert
instead of being required along
with std::default_initializable<A>
.
This isn't a new trick, and takes advantage of the fact that the
static_assert
isn't considered to be part of the immediate context mentioned
in the C++ standard:
Invalid types and expressions can result in a deduction failure only in the immediate context of the deduction substitution loci.
[Note 6: The substitution into types and expressions can result in effects such as the instantiation of class template specializations and/or function template specializations, the generation of implicitly-defined functions, etc. Such effects are not in the "immediate context" and can result in the program being ill-formed. end note]
Which you might consider a bit odd. The code doesn't compile, so why should all of these constraints be satisfied? I think it's odd, but there's nothing that can really be done if incomplete type support is needed.
The previous iteration of their trick was a bit weirder but ultimately the same, they added another template instantiation to remove the failure from the immediate context of the substitution locus:
template <typename TT = T>
explicit constexpr polymorphic()
requires(std::default_initializable<TT> && std::copy_constructible<TT>)
: polymorphic(std::allocator_arg_t{}, A{}) {}
Adding the extra level of template specialization through TT
allows
substitutions with T
that aren't default initializable to "work" even though
they "shouldn't".
Conclusion
This proposal has evolved over time. At one point,
std::default_initializable<polymorphic<T>>
told the truth! But to be included
in the C++ standard, there's a minimum quota of weirdness you have to meet.
For indirect
and polymorphic
, that also includes the valueless_after_move
member function, but all of the valueless_*
stuff is another topic.