Search code examples
c++language-lawyerc++23constructor-overloadingforwarding-reference

Inheriting constructors and forwarding reference


Consider the following two classes:

struct Base {
    Base() = default;
    Base(auto&&);
};

struct Derived : Base {
    using Base::Base;
};

If I try the following test with Type = Base and Type = Derived, I get different results (demo).

Type t;
const Type ct;

Type t1 = t;
Type t2 = ct;
Type t3 = std::move(t);
Type t4 = std::move(ct);
Type = Base Type = Derived
t1 calls forwarding reference ctor. calls copy ctor.
t2 calls copy ctor. calls copy ctor.
t3 calls move ctor. calls move ctor.
t4 calls forwarding reference ctor. calls copy ctor.

Why is there a difference for t1 and t4?


Solution

  • So first I'll explain the "unsurprising" behaviour and then I'll go on to explain why we get the "surprising" behaviour that, in the derived case, the inherited constructor template doesn't get called in the t1 and t4 cases.

    The "unsurprising" behaviour is due to the following rules:

    • Binding a non-const reference to a non-const value is a better implicit conversion sequence than binding a const reference to a non-const value ([over.ics.rank]/3.2.6), so in the t1 case, the constructor template wins.
    • In cases where two functions have equally good implicit conversion sequences for all pairs of corresponding arguments, the non-template wins ([over.match.best.general]/2.4), so in the t2 case the copy constructor wins over the constructor template, and in the t3 case the move constructor wins over the constructor template.
    • In the t4 case, the move constructor isn't viable because Base&& can't bind to a const Base value. The copy constructor is still viable (binding const Base& to a const Base xvalue) but the constructor template wins because the instantiated parameter type is const Base&&, and binding an rvalue reference to an rvalue is better than binding a const lvalue reference to an rvalue ([over.ics.rank]/3.2.3)

    So, why don't these rules give the same results in the derived case for t1 and t4? It's because of an obscure rule, [over.match.funcs.general]/9:

    [...] A constructor inherited from class type C ([class.inhctor.init]) that has a first parameter of type “reference to cv1 P” (including such a constructor instantiated from a template) is excluded from the set of candidate functions when constructing an object of type cv2 D if the argument list has exactly one argument and C is reference-related to P and P is reference-related to D.

    To see why we have this rule, consider the following:

    struct B {
        B(int, int) {}
    };
    
    struct D : B {
        using B::B;
    };
    
    D d(1, 2);  // OK
    D d(b);     // error
    

    The above is what, I think, most users would want: D can be initialized from two ints because it inherits that constructor from B, but the user isn't expecting this to also allow D to be initialized from a B. But D does inherit the copy constructor of B, i.e., a constructor with a parameter type of const B&, so we need to have another rule that prevents that inherited constructor from being used. That's what [over.match.funcs.general]/9 does. (It was added by CWG2356.)

    In this case, when initializing a Derived from a non-const lvalue of Derived, although the inherited constructor template produces a constructor taking Derived&, that constructor is excluded by overload resolution because C (here, Base) is reference-related to the referenced parameter type P (here, Derived) which in turn is reference-related to D (here, Derived). Similarly, when initializing a Derived from a const rvalue of Derived the constructor produced by instantiating the inherited constructor template is not a candidate. (That's also true in the t2 and t3 cases as well, although in those cases it doesn't matter, because it would lose overload resolution, like in the Base initialization cases, even if it were a candidate.)