Search code examples
c++14c++17copy-constructorautomove-constructor

Disallow the use of auto for a given class, C++14 vs. C++17 update


What is the feature that allows me use auto for non-copyable (and non-movable) types in C++17 and didn't for C++14?

Consider the following code:

struct A{
    A(A const&)=delete;
    A(A&&)=delete;
};

int main(){
    auto   a1 = A{}; // ok in C++17, not ok in C++14
    auto&& a2 = A{}; // ok in C++17, ok in C++14
}

It turns out that this was invalid code in C++14 but it is valid in C++17. The behavior is consistent in clang and gcc: https://godbolt.org/z/af8mEc

The reason I ask is because until recently I was making my classes (that represent references) non-copyable, among other things to disallow use of auto, but unfortunately it turns out that now the technique doesn't work in C++17.

(In other words, I think the behavior in C++14 was conceptually right.)

Why is auto a1 = A{}; valid for a non-copyable class in C++17? Is some kind of new RVO case?

I think auto is semantically broken for several arguable reasons but at least in C++14 I could prevent the use of auto (but allowed the use of auto&&).

Is there another way at all of preventing the use of auto a = A{}; for a particular class in C++17, or not anymore?

Note: I asked this question some time ago Is there a way to disable auto declaration for non regular types? and found that the solution back then was to disable the copy and move constructors in C++14, this made both conceptual and syntactic sense however this is not the case anymore in C++17.


Solution

  • In C++17, if you give a user a prvalue of some type, the user can always use it to initialize a variable of that type.

    Essentially, the definition of a prvalue has changed. In C++14, it was an object; specifically, a temporary object. Therefore, a statement like A a = prvalue_of_type_A; means to move the temporary object into a. The move could be elided, but it is logically a move and therefore A must support move construction.

    In C++17, a prvalue is nothing more than an initializer for an object. Which object it initializes depends on how it gets used. Using a prvalue to initialize a variable of the prvalue's type means that you initialize that object. There is no copy or move. You can look at it from the perspective that the prvalue is a nameless object, and A a = prvalue_of_A; is merely giving a name to that object, not creating a new object.

    And no, you can't get around it. So long as you are dealing with a genuine prvalue of some type, auto a = prvalue; will always deduce the type of the prvalue and directly initialize the object a, with no copy/move.


    In other words, I think the behavior in C++14 was conceptually right.

    Well, let's investigate that.

    This investigation begins and ends with the full realization that you did not prevent auto a = A{}; from working. You prevented copy/move construction, which has the side-effect of preventing that syntax. This is why guaranteed elision made your "fix" meaningless.

    By denying a type the ability to be copy/move constructed, you did indeed prevent this syntax. But there was a whole lot of collateral damage incurred along the way.

    The post you linked to gave the rationale for wanting to disable this syntax as the type is non-Regular, and this makes the use of auto "tricky"... for some reason. Ignoring whether those unstated reasons are valid, your solution didn't just remove the "tricky" syntax. It prevented you from doing basic things to the object.

    Consider the ramifications of applying this to string_view. You've got this view type, and you want to modify it through a multi-step process. But you want to keep the original view around unmodified. So naturally, you copy the... oh wait, string_view is non-Regular, so you made copying it illegal just to prevent string_view sv = prvalue;. Oops. So now I have to go back to my original source of the string_view to get another one. Assuming I have access to the source, that I wasn't just passed it as a const& parameter.

    Essentially, you're saying that the best way to swat a fly is to use a sledgehammer. No matter how annoying the fly is, the wall behind it was probably more important.

    So, is guaranteed elision "conceptually right"? Well, one of the main justifications of guaranteed elision was to allow functions that return prvalues to work even for types that are immobile. This is actually quite important. Before C++17, if you wanted to give your class factory functions, but the class itself should logically be immobile, you were out of luck. You could pick one or the other, but not both.

    Let's take your subrange example. In C++14, if I wanted to write a function that simply provided defaults for some of the constructor's parameters, or used a specific container, or whatever, that wasn't possible. I couldn't even write a container that had a function which returned a subrange, since I would have to construct one, which (unless I used a raw braced-init-list, which isn't always possible) would provoke a copy/move.

    Guaranteed elision fixes all of these problems. You can make immobile classes with private constructors which can only be created through factories. You can make functions that build immobile objects through public interfaces. And so forth.

    The C++17 behavior makes your immobile non-Regular types more useful and capable. Is that not "conceptually right"?