notadam
notadam

Reputation: 2854

Why isn't the most appropriate constructor called in this case?

Consider the following class:

class foo {
    int data;
public:
    template <typename T, typename = enable_if_t<is_constructible<int, T>::value>>
    foo(const T& i) : data{ i } { cout << "Value copy ctor" << endl; }

    template <typename T, typename = enable_if_t<is_constructible<int, T>::value>>
    foo(T&& i) : data{ i } { cout << "Value move ctor" << endl; }

    foo(const foo& other) : data{ other.data } { cout << "Copy ctor" << endl; }

    foo(foo&& other) : data{ other.data } { cout << "Move ctor" << endl; }

    operator int() { cout << "Operator int()" << endl; return data; }
};

Of course it doesn't make much sense to take a single int by any kind of reference, but this is just an example. The data member could be very expensive to copy, hence all the move semantics.

That fancy template basically enables any type from which data can be constructed. So a foo object can be constructed either by copying or moving a value of any type that satisfies this criteria, or simply by copying or moving another object of type foo. Pretty straight forward so far.

The problem occurs when you try to do something like this:

foo obj1(42);
foo obj2(obj1);

What this should do (at lest in my opinion) is to construct the first object by moving the value 42 into it (since it's an rvalue), and then construct the second object by copying the first object. So what this should print out is:

Value move ctor
Copy ctor

But what it actually prints out is:

Value move ctor
Operator int
Value move ctor

The first object gets constructed just fine, no problem there. But instead of calling the copy constructor to construct the second object, the program converts the first object into another type (via the conversion we defined) and then calls another constructor of foo which can move from that type (since it's an rvalue at that point).

I find this very strange, and it's definitely not the behavior I would want from this piece of code. I think it makes more sense to just call the copy constructor upon constructing the second object, as that seems way more trivial given the type of argument I supplied.

Can anyone explain what happens here? Of course I understand that since there's a user-defined conversion to int, this is a perfectly valid path to take, but I cannot make sense of it. Why would the compiler refuse to simply call the constructor which has the exact same argument type as the supplied value? Wouldn't that be the most trivial thing to do, therefore the default behavior? Calling the conversion operator does perform a copy as well, so I don't think that is faster or more optimal than simply calling the copy constructor either.

Upvotes: 4

Views: 122

Answers (2)

user743382
user743382

Reputation:

Your template "move" constructor (with T = foo &) has a parameter of type foo &, which is a better match than your copy constructor, since that only takes const foo &. Your template constructor then fills data by converting i to int, invoking operator int().

The simplest immediate fix could be to use enable_if to restrict your move constructor to move operations: if T is deduced as an lvalue reference type (meaning your T&& i would collapse to an lvalue reference too), force a substitution failure.

template <typename T, typename = enable_if_t<is_constructible<int, T>::value>,
                      typename = enable_if_t<!is_lvalue_reference<T>::value>>
foo(T&& i) : data( std::move(i) ) { cout << "Value move ctor" << endl; }

Note: since your parameter has a name, inside the constructor, just like all other named objects, it's an lvalue. Given that you want to move from it, you can use std::move.

More generally, you could use perfect forwarding (accepting both lvalues and rvalues), and only remove foo itself as a special exception:

template <typename T, typename = enable_if_t<is_constructible<int, T>::value>
                    , typename = enable_if_t<!is_same<decay_t<T>, foo>::value>
foo(T&& i) : data( std::forward<T>(i) ) { cout << "Forwarding ctor" << endl; }

This would replace your value copy and value move constructor.

Another note: is_constructible<int, T>::value is a test that tells you whether data(std::forward<T>(i)) would be well-formed. It does not test whether data{std::forward<T>(i)} would be well-formed. A T for which the result is different is long, since the long to int conversion is a narrowing conversion, and narrowing conversions are not allowed in {}.

Upvotes: 5

user5058091
user5058091

Reputation:

It happens because of operator int().

because of operator int(), obj1 calls operator int() and casts itself as an int.


Possible solutions:

  1. use static_cast<foo>. this will cast the variable that normally should cast as an int, to a foo.

  2. Anyone please edit this question and fill this. I have no more ideas.

Upvotes: 0

Related Questions