kaashif's blog

Programming, with some mathematics on the side

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 type T 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]

-- https://eel.is/c++draft/temp.deduct.general#8

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.