Search code examples
c++visual-c++language-lawyerunique-ptrc++23

Did the rules for nullptr init of unique_ptr change in C++23?


This code compiles with MSVC from VS 2022 in C++20 mode. It failes in C++23 mode. (/std:c++latest)

#include <memory>

struct A;

struct B {
    B();
    ~B();

    std::unique_ptr<A> ptr{nullptr};
};

int main(){
    B b;
}

Current versions of GCC and clang accept this code in C++23 mode.

Live Code on Compiler Explorer

MSVC in C++23 mode:

C:/data/msvc/14.38.33133/include\memory(3169): error C2027: use of undefined type 'A'
<source>(3): note: see declaration of 'A'
C:/data/msvc/14.38.33133/include\memory(3169): note: the template instantiation context (the oldest one first) is
<source>(9): note: see reference to class template instantiation 'std::unique_ptr<A,std::default_delete<A>>' being compiled
C:/data/msvc/14.38.33133/include\memory(3205): note: see reference to class template instantiation 'std::default_delete<A>' being compiled
C:/data/msvc/14.38.33133/include\memory(3168): note: while compiling class template member function 'void std::default_delete<A>::operator ()(_Ty *) noexcept const'
        with
        [
            _Ty=A
        ]
C:/data/msvc/14.38.33133/include\memory(3280): note: see the first reference to 'std::default_delete<A>::operator ()' in 'std::unique_ptr<A,std::default_delete<A>>::~unique_ptr'
C:/data/msvc/14.38.33133/include\memory(3169): error C2338: static_assert failed: 'can't delete an incomplete type'
C:/data/msvc/14.38.33133/include\memory(3170): warning C4150: deletion of pointer to incomplete type 'A'; no destructor called
<source>(3): note: see declaration of 'A'

Microsoft answerd me (not public available yet) that the new behavior is due to constexpr std::unique_ptr in C++23 mode and they are just following the standard with this.

Is this corrent? Are GCC and clang are wrong to accept this code in C++23 mode?


I'll add some more explanation to what happens here, because some of the comments indicate that this is not obvious.

The constructor of std::unique_ptr<A> is called here. It doesn't call the constructor of the incomplete type A, because it is initialized with a nullptr. This part seams to be fine for MSVC in C++23 mode.

The destructor of std::unique_ptr<A> is not called here. It is called from the destructor of B which is defined in another source code file, where A is a complete type. This works with GCC and clang. It also works with MSVC in C++20 mode. In C++23 mode MSVC complains, that it can not instantiate the destructor of std::unique_ptr<A> because A is not a complete type here.

In my opinion the destructor should never be instantiated from this code unit.

The MSVC support on the other hand told me, that this is valid for C++23, because std::unique_ptr is now constexpr.


Solution

  • I have spent some time researching the question over the last few days and I think I can now give the answer myself.

    No, the rules didn't change.

    It is a compiler bug in MSVC that was not noticed before because unique_ptr was not constexpr. GCC and clang have different but releated bugs.

    The essential rules are as follows:

    temp.inst-11

    An implementation shall not implicitly instantiate a function template, a variable template, a member template, a non-virtual member function, a member class or static data member of a templated class, or a substatement of a constexpr if statement ([stmt.if]), unless such instantiation is required. [...]

    P0859 which is a C++20 Defect Report against C++11 defines, that this also applies to constexpr functions.


    During my investigation it turned out that MSVC, GCC and clang behave differently with regard to template instantiations and, in my opinion, all of them are not fully compliant to the standard.

    The following bug reports have been created:

    Many thanks to everyone who took part in the discussion in the comments and bug reports! I will keep this answer updated whenever new information is available from these bug reports.


    To understand what is going wrong, I would like to demonstrate the different behavior of the three compilers.

    GitHub user frederick-vs-ja has reduced the problem in the MS STL Bug Report to a minimal example.

    template <typename T>
    struct Holder {
        Holder() = default;
        constexpr ~Holder() { static_assert(sizeof(T) || true); }
    };
    
    struct Incomplete;
    
    struct Class {
        Class();
        ~Class();
    
        Holder<Incomplete> v{};
    };
    
    int main() { [[maybe_unused]] Class v; }
    

    I have extended this example with some variants and tested the compilers with them. Syntactically, there are 7 ways to call the default constructor of Holder:

    • Holder<Incomplete> a; default-initialization
    • Holder<Incomplete> b{}; direct-list-initialization
    • Holder<Incomplete> c = {}; copy-list-initialization
    • Holder<Incomplete> d = Holder<Incomplete>(); copy-direct-initialization
    • Holder<Incomplete> e = {Holder<Incomplete>()}; copy-list-initialization
    • Holder<Incomplete> f = Holder<Incomplete>{}; copy-direct-initialization
    • Holder<Incomplete> g = {Holder<Incomplete>{}}; copy-list-initialization

    Then we can change Holder in two ways. (Thanks to Github user fsb4000 in the MS STL bug report!)

    First we can remove the explicit constructor definition, so the compiler generates this constructor implicitly:

    template <typename T>
    struct Holder {
        constexpr ~Holder() { static_assert(sizeof(T) || true); }
    };
    

    Second we can remove the constexpr from the destructor:

    template <typename T>
    struct Holder {
        Holder() = default;
        ~Holder() { static_assert(sizeof(T) || true); }
    };
    

    A third relevant change can be applied to B. (Thanks to Jiang An in the GCC bug report!) We can make it a template, by giving it a default template argument:

    template <typename = void>
    struct Class {
        Class();
        ~Class();
    
        Holder<Incomplete> v{};
    };
    

    So we have 8 code examples with 7 kinds of initialization. I created a table with what works with which compiler. I tested with MSVC 19.38, GCC 13.2 and clang 17.0.1. Each table cell links to the live code, where the trunk versions of the compilers are used additionally.

    • Case 1: Implicit Holder ctor, non-constexpr dtor, non-template Class
    • Case 2: Explicit Holder ctor, non-constexpr dtor, non-template Class
    • Case 3: Implicit Holder ctor, constexpr dtor, non-template Class
    • Case 4: Explicit Holder ctor, constexpr dtor, non-template Class
    • Case 5: Implicit Holder ctor, non-constexpr dtor, template Class
    • Case 6: Explicit Holder ctor, non-constexpr dtor, template Class
    • Case 7: Implicit Holder ctor, constexpr dtor, template Class
    • Case 8: Explicit Holder ctor, constexpr dtor, template Class

    The order is: ✅/❌ MSVC ; ✅/❌ GCC ; ✅/❌ clang

    Cases d, e, f and g can apparently be considered as one case.