c++language-lawyerimplicit-conversionoverload-resolutionexplicit-conversion

GCC14 performes multiple implicit conversions instead of one matching explicit conversion


#include <cstdio>
#include <string>

class A {
  std::string data;

  public:

  A() = default;

  explicit A (const char* data) : data(data) {}

  operator const char* () const;
  explicit operator std::string() &&;
};

A::operator const char*() const {
  printf("A -> const char*\n");
  return data.c_str();
}

A::operator std::string() && {
  printf("A -> std::string\n");
  return data;
}

int main() {
  A a("lorem");
  std::string s(std::move(a));
  printf("%s\n", s.c_str());
  return 0;
}

The above code prints "A -> std::string" on gcc13 and "A -> const char*" on gcc14. Clang (18.1.8) does the same thing as gcc13. All compilers were called with -Wall -pedantic --std=c++17

The code is a minimal reproduction case.

I'm trying to add std::string support to a custom class A in a very old project. Unfortunately A must have the implicit conversion to a c-str to avoid breaking existing code.

I had a working version but it's broken on gcc14.

What I want is for the move conversion operator to be called when a call site attempts to construct std::string from an rvalue reference of A. All of c-cast, function-cast, static_cast and initialization worked on gcc13.

What I think is happening is that on gcc13, the move conversion is called, as expected, and only a move occurs. On gcc14, a two step implicit conversion of is taken, using the implicit A::operator const char*() and std::string(const char*). This creates the second string using a copy instead of a move.

Naming the conversion operator like C c = std::move(a).operator std::string(); does call the desired conversion.

Is there a way to convince gcc14 of the desired behavior?

EDIT:

The std::string operator has to be explicit in my case. Existing code uses standard library functions that are overloaded for both c-str and std::string. Removing explicit would make these calls ambiguous.

I also tried to make a more generic reproduction case. This happens even if all the involved types are custom.

#include <cstdio>

class B {
};

class C;

class A {
  B data;

  public:

  A() = default;

  explicit A (B data) : data(data) {}

  operator B () const;
  explicit operator C() const;
};

class C {
  B data;

  public:

  C(B data) : data(data) {
    printf("C from B\n");
  }
};

A::operator B() const {
  printf("A -> B\n");
  return data;
}

A::operator C() const {
  printf("A -> C\n");
  return C(data);
}

int main() {
  A a(B{});
  C s = static_cast<C>(a);
  return 0;
}

With gcc13 and clang this prints

A -> C
C from B

and with gcc14 it prints

A -> B
C from B

Solution

  • GCC 14 is formally correct (and the other compiler (versions) aren't):

    std::string s(std::move(a)); is direct-initialization, which performs overload resolution against all constructors of std::string, and only those, with the argument list (std::move(a)).

    In this overload resolution the constructor of std::string which takes const char* is the only viable one. You expect the move constructor with argument type std::string&& to be used, but that one is not viable. In the conversion of function arguments, explicit conversion functions are not allowed to be used.

    That said, there is an open CWG issue 2327 related to this. It is surprising that conversion functions can't be used directly for direct-initialization and there also can't be any copy elision for the intermediate temporary object initialized from the conversion function call when the move constructor is selected.

    However, the paper P2828R2, which is the current proposal to fix the CWG issue, would not make your specific case work as you intend either.

    My feeling is that the issue needs to be resolved so that conversion operators are considered together with constructors as suggested in CWG 2327. Otherwise it causes even static_cast, which is supposed to perform explicit conversions but still uses direct-initialization in the end, to not work with explicit constructors for class types. The behavior of the other compilers is here more reasonable.

    To do the conversion that you want you need to call the operator directly:

    auto s = std::move(a).operator std::string();
    

    There is, as far as I can tell, no other way to select that conversion function for initialization of std::string, as long as you do not change the class definition.