Я столкнулся со странным поведением, когда gcc и clang выбирают разные перегруженные конструкторы, когда их аргументы неявно преобразуются из определяемых пользователем операторов преобразования.
Соответствующий код находится здесь:
#include <cstdio>
#include <utility>
#include <type_traits>
template <typename T>
class foo {
T& t_;
public:
foo(T& t) : t_(t) {}
operator T() const & { return t_; }
operator T&&() && { return std::move(t_); }
};
class bar {
int val_;
public:
bar(int v) : val_(v) {}
bar(const bar& b) : val_(b.val_) { printf("copy constructed\n"); }
bar& operator=(const bar& b) { printf("copy assigned\n"); val_ = b.val_; return *this; }
bar(bar&& mv) : val_(mv.val_) { printf("move constructed\n"); mv.val_ = -1; }
bar& operator=(bar&& mv) { printf("move assigned\n"); val_ = mv.val_; mv.val_ = -1; return *this; }
};
int main() {
bar v(1);
foo<bar> f(v);
bar v2(std::move(f));
}
Класс foo
является оболочкой для типа T
, который, как ожидается, будет неявно преобразован в T
или T&&
, если он является ссылкой на rvalue (при преобразовании с помощью std::move
).
Однако некоторые компиляторы предпочитают T
, а не T&&
, хотя это ссылка на rvalue.
Результат выглядит так:
⟩ clang++-15 -std=c++17 test.cpp && ./a.out
copy constructed
⟩ clang++-15 -std=c++14 test.cpp && ./a.out
move constructed
⟩ g++ -std=c++14 test.cpp && ./a.out
move constructed
⟩ g++ -std=c++17 test.cpp && ./a.out
move constructed
Только с clang и -std=c++17
или выше конструктор копирования предпочтительнее конструктора перемещения.
Версии компилятора:
⟩ g++ --version
g++ (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
⟩ clang++-15 --version
Ubuntu clang version 15.0.4-++20221102053308+5c68a1cb1231-1~exp1~20221102053355.92
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
Почему приоритет отличается для разных компиляторов и версий C++? Или я нарушаю какое-то правило?
В
bar v2(std::move(f));
разрешение перегрузки верхнего уровня находится между конструктором копирования и конструктором перемещения bar
. У bar
есть третий конструктор, который принимает int
, но очевидно, что он не выбран, поэтому я не буду его учитывать.
Какая бы функция преобразования foo<bar>
не была выбрана, результатом этой функции преобразования будет значение r, поэтому предпочтительным будет конструктор перемещения bar
. С учетом сказанного я также собираюсь игнорировать конструктор копирования, не углубляясь в стандарты. (Не похоже, что использование конструктора перемещения является частью разногласий здесь. Хотя программа ОП печатает «копия построена», эта конструкция копии выполняется operator T
; конструктор копирования не тот, который вызывается на v2
сам.) Вопрос в том, какая неявная последовательность преобразования будет использоваться для преобразования rvalue типа foo<bar>
в тип аргумента bar&&
. Все ссылки будут на стандарт C++17, так как вопрос был отмечен тегом [c++17].
Согласно [over.best.ics]/1, последовательность неявного преобразования «регулируется правилами инициализации объекта или ссылки одним выражением». Таким образом, мы должны обратиться к [dcl.init.ref], который управляет инициализацией ссылок. Инициализируемая ссылка имеет тип bar&&
, а инициализация является копией-инициализацией из rvalue типа foo<bar>
. Достигнутый случай описан в п. 5.2.1 этого раздела:
Если выражение инициализатора
- [неприменимый падеж опущен], или
- имеет тип класса (т. е.
T2
является типом класса), гдеT1
не связан со ссылкой наT2
и может быть преобразован в rvalue или функцию lvalue типа «cv3T3
», где «cv1T1
» совместим со ссылкой с «cv3T3
» (см. 16.3.1.6),затем [...] результат преобразования [...] называется преобразованным инициализатором. Если преобразованный инициализатор является значением prvalue, его тип
T4
настраивается на тип «cv1T4
» (7.5) и применяется временное преобразование материализации (7.4). В любом случае ссылка привязывается к результирующему glvalue (или к соответствующему подобъекту базового класса).
Мы знаем, что инициализатор может быть преобразован либо в xvalue, либо в prvalue типа bar
, а bar
совместим по ссылке сам с собой, поэтому преобразование будет выполнено, и ссылка будет привязана к результату этого преобразования. Вопрос только в том, как осуществляется это преобразование: с помощью operator bar
или operator bar&&
?
Чтобы ответить на этот вопрос, мы должны взглянуть на упомянутый раздел 16.3.1.6, также известный как [over.match.ref]:
При условиях, указанных в 11.6.3, ссылка может быть привязана непосредственно к glvalue или классу prvalue, которые являются результатом применения функции преобразования к выражению инициализатора. Разрешение перегрузки используется для выбора вызываемая функция преобразования. Предполагая, что «ссылка на cv1
T
» — это тип инициализируемой ссылки, а «cvS
» — это тип выражения инициализатора, сS
типом класса, функции-кандидаты выбираются следующим образом:
- Рассмотрены функции преобразования
S
и его базовых классов. Те неявные функции преобразования, которые не скрыты внутриS
и дают тип «ссылка lvalue на cv2T2
» (при инициализации lvalue ссылка или ссылка rvalue на функцию) или «cv2T2
» или «ссылка rvalue на cv2T2
» (при инициализации ссылки rvalue или ссылки lvalue на функцию), где «cv1T
» совместим со ссылками (11.6.3) с «cv2T2
» являются функциями-кандидатами. Для прямой инициализации [...]Список аргументов имеет один аргумент, который является выражением инициализатора. [Примечание: этот аргумент будет по сравнению с неявным параметром объекта функций преобразования. -конец примечания]
Здесь T
— ссылочный тип cv-unqualified, то есть bar
. Существует одна функция преобразования, которая дает bar
(т. е. T2
, где T2
— это bar
), и другая, которая дает bar&&
(т. е. «ссылка rvalue на T2
», где T2
— это bar
). Поскольку bar
совместим по ссылкам с T2
(то есть самим bar
) в обоих случаях, обе функции преобразования являются кандидатами. Необходимо выполнить разрешение перегрузки, чтобы определить, какой из них вызывать. Здесь аргумент по-прежнему std::move(f)
, то есть rvalue типа foo<bar>
, но параметр является подразумеваемым параметром объекта:
operator T
подразумеваемый параметр объекта имеет тип foo<bar> const &
, поскольку оператор был объявлен с const &
.operator T&&
подразумеваемый параметр объекта имеет тип foo<bar>&&
, поскольку оператор был объявлен с &&
.Чтобы выполнить разрешение перегрузки, мы рассматриваем соответствующие последовательности неявного преобразования, а затем пытаемся определить, лучше ли одна, чем другая. Оба являются преобразованиями идентичности, поскольку rvalue foo<bar>
может быть напрямую привязана либо к foo<bar> const &
, либо к foo<bar>&&
(см. [over.ics.ref]/1). Но, как мы знаем, привязка ссылки rvalue к rvalue лучше, чем привязка ссылки lvalue к rvalue. Это правило [over.ics.rank]/3.2.3:
Стандартная последовательность преобразования
S1
является лучшей последовательностью преобразования, чем стандартная последовательность преобразованияS2
, если
- [...] или, если не так,
- [...] или, если не так,
S1
иS2
являются привязками ссылок (11.6.3), и ни один из них не ссылается на неявный объектный параметр нестатической функции-члена, объявленной без квалификатора ref, аS1
связывает ссылку rvalue с rvalue, аS2
связывает ссылку lvalue или, если бы не это, [...]
(Оговорка здесь не применяется; обе функции имеют квалификаторы ref).
Поскольку неявная последовательность преобразования для неявного параметра объекта лучше в случае operator T&&
, чем operator T
, первая является наилучшей жизнеспособной функцией.
ГЦК правильный. operator T&&
следует вызвать, а затем конструктор перемещения. Clang в режиме C++17 вызывает operator T
(который внутренне выполняет конструкцию копирования). Здесь нет вызова конструктора перемещения, потому что Clang его исключил.
Это наш ключ к разгадке того, что поведение Clang, вероятно, связано с основной проблемой 2327. Я думаю, что сопровождающие Clang, вероятно, оптимистично реализовали некоторые предложенные решения этой проблемы (хотя не похоже, чтобы Ричард Смит предоставил подробную формулировку). Если проблема решена таким образом, который согласуется с поведением, которое вы наблюдаете, и комитет одобрит ее как отчет о дефекте C++17, поведение Clang будет считаться правильным. Если, с другой стороны, это будет разрешено способом, совместимым с поведением GCC, Clang, вероятно, придется измениться.
Спасибо, это имеет смысл. Я уже сообщал об этой проблеме на Clang/LLVM GitHub и процитировал ваш ответ: github.com/llvm/llvm-project/issues/58967
Похоже на клановый баг.