Is lvalue to rvalue conversion not applied on empty class object passed by value

154 views Asked by At

I wrote the following program where if a class have a non-static data member then when an object of that type is passed as argument by value, then it can't be used in a constexpr context. After searching for the issue on the web, I came across this gcc bug where some user said something like when the class is empty, then no lvalue to rvalue transformation is applied which is why the A in the below example works but not B. But then other user is saying that this isn't the case.

I want to know how exactly the standard allows the empty class case to work but not the non-empty class case. Demo

struct A {
    static constexpr int value = 42;
};
struct B
{
    static constexpr int value = 42;
    int mem{};
};

constexpr int f(A a) { return A::value; }
constexpr int f(B b) { return B::value; } 

int main() {
    A a; 
    constexpr int aconst = f(a); //works in all compilers

    B b;
    constexpr int bconst = f(b); //fails in all compilers, why?
}

Gcc says:

<source>: In function 'int main()':
<source>:18:31: error: the value of 'b' is not usable in a constant expression
   18 |     constexpr int bconst = f(b); //fails in all compilers, why?
      |                               ^
<source>:17:7: note: 'b' was not declared 'constexpr'
   17 |     B b;
      |       

Basically I want to know if the claim that lvalue to rvalue transformation is bypassed in case of empty class case is true or not. And if it is, where/how exactly according to the standard.

2

There are 2 answers

2
Brian Bi On BEST ANSWER

[dcl.init.general]/14

The initialization that occurs [...] as well as in argument passing [...] is called copy-initialization.

The semantics of the copy-initializations in the two function calls in the question are governed by [dcl.init.general]/16.6.2:

Otherwise, if the destination type is a (possibly cv-qualified) class type:

  • [...]

  • Otherwise, if the initialization is direct-initialization, or if it is copy-initialization where the cv-unqualified version of the source type is the same class as, or a derived class of, the class of the destination, constructors are considered. The applicable constructors are enumerated ([over.match.ctor]), and the best one is chosen through overload resolution ([over.match]). Then:

    • If overload resolution is successful, the selected constructor is called to initialize the object, with the initializer expression or expression-list as its argument(s).
    • [...]
  • [...]

The initialization of the parameter a from the argument a, or the parameter b from the argument b, is by constructor. In these cases the copy constructors are used (I hope this is obvious enough that I don't need to explain it).

Since A and B don't have user-declared copy constructors, [class.copy.ctor]/6 applies:

If the class definition does not explicitly declare a copy constructor, a non-explicit one is declared implicitly. If the class definition declares a move constructor or move assignment operator, the implicitly declared copy constructor is defined as deleted; otherwise, it is defaulted ([dcl.fct.def]). The latter case is deprecated if the class has a user-declared copy assignment operator or a user-declared destructor ([depr.impldec]).

The semantics of an implicitly declared copy constructor are given by [class.copy.ctor]/14.

The implicitly-defined copy/move constructor for a non-union class X performs a memberwise copy/move of its bases and members. [...] The order of initialization is the same as the order of initialization of bases and members in a user-defined constructor (see [class.base.init]). Let x be either the parameter of the constructor or, for the move constructor, an xvalue referring to the parameter. Each base or non-static data member is copied/moved in the manner appropriate to its type:

  • if the member is an array, each element is direct-initialized with the corresponding subobject of x;
  • if a member m has rvalue reference type T&&, it is direct-initialized with static_cast<T&&>(x.m);
  • otherwise, the base or member is direct-initialized with the corresponding base or member of x.

Virtual base class subobjects shall be initialized only once by the implicitly-defined copy/move constructor (see [class.base.init]).

Since A has neither bases nor non-static data members, its copy constructor does nothing. B on the other hand has a single non-static data member of type int, so according to the above, its implicit copy constructor direct-initializes mem from the mem member of the object being copied, which is a const lvalue of type int (because the parameter to the copy constructor has type const B&).

The semantics of a direct-initialization of an int from a const lvalue of type int are given by [dcl.init.general]/16.9:

  • [...]
  • Otherwise, the initial value of the object being initialized is the (possibly converted) value of the initializer expression. A standard conversion sequence ([conv]) is used to convert the initializer expression to a prvalue of the cv-unqualified version of the destination type; no user-defined conversions are considered. If the conversion cannot be done, the initialization is ill-formed. When initializing a bit-field with a value that it cannot represent, the resulting value of the bit-field is implementation-defined. [...]

The destination type is int, so we must form a standard conversion sequence from an lvalue of const int to a prvalue of int. The standard conversion that can accomplish this is the lvalue-to-rvalue conversion. See [conv.lval]/1 (footnotes omitted):

A glvalue of a non-function, non-array type T can be converted to a prvalue. If T is an incomplete type, a program that necessitates this conversion is ill-formed. If T is a non-class type, the type of the prvalue is the cv-unqualified version of T. Otherwise, the type of the prvalue is T.

Lvalue-to-rvalue conversions are applied when the rules of the language either explicitly require them to be performed, or when the rules of the language call for a standard conversion sequence to be performed from some source type to some destination type and the lvalue-to-rvalue conversion ends up being a necessary step in that standard conversion sequence. The copy-initialization of an empty class type such as A from the same type is not one of these situations since, based on the above, it is specified to have the behaviour of calling a constructor (not performing a standard conversion) and there is no other rule that demands the lvalue-to-rvalue conversion in such a case. However, copying a glvalue of a scalar type always entails an lvalue-to-rvalue conversion.

8
user17732522 On

When you pass a scalar type by-value to a function, then the initialization of the function parameter will directly involve an lvalue-to-rvalue conversion to read the value of the passed object.

When you pass a class type by-value to a function, then you are actually initializing the function parameter with a call to the classes copy constructor.

In the call to the copy constructor the source object is taken by-reference, so there is no immediate lvalue-to-rvalue conversion there.

An lvalue-to-rvalue conversion will happen only if inside the copy constructor a (scalar) non-static data member of the class is copied (which again requires obtaining its value).