显式构造函数和嵌套初始化列表

10

以下代码可以在大多数现代C++11兼容的编译器(GCC >= 5.x,Clang,ICC,MSVC)上成功编译。

#include <string>

struct A
{
        explicit A(const char *) {}
        A(std::string) {}
};

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

int main( void )
{
        B b1({{{"test"}}});
}

但是为什么它可以在第一次编译时通过,这四个编译器都是如何解释那段代码的?

为什么MSVC可以在没有B(B&) = delete;的情况下编译它,而其他3个编译器都需要它?

当我删除不同签名的复制构造函数时(例如B(const B&) = delete;),为什么它会在除了MSVC外的所有编译器中失败?

这些编译器是否都选择了相同的构造函数?

为什么Clang发出以下警告?

17 : <source>:17:16: warning: braces around scalar initializer [-Wbraced-scalar-init]
        B b1({{{"test"}}});

1
另一个有趣的问题是,当您删除 explicit 时,GCC 调用 A(const char*),而 Clang 调用 A(std::string) - xskxzr
1
使用哪个版本的MSVC? 在过去的7年中,它们在初始化方面有各种怪癖。 - M.M
1
当微软首次添加移动语义时,他们没有为任何类提供默认的移动构造函数(用户必须声明它们,否则它们不存在)。 - M.M
@M.M 尝试使用 v141(VS2017),默认情况下具有移动构造函数。v140 也应该是如此。 - Ext3h
3个回答

5

我不会解释编译器的行为,而是尝试解释标准的内容。

主要示例

为了从{{{"test"}}}直接初始化b1,重载决议应用于选择最佳的B构造函数。由于没有从{{{"test"}}}B&的隐式转换(列表初始化程序不是左值),因此构造函数B(B&)不可行。然后我们专注于构造函数B(A),并检查它是否可行。

为了确定从{{{"test"}}}A的隐式转换序列(我将使用符号{{{"test"}}} -> A来简化),重载解析应用于选择A的最佳构造函数,因此我们需要根据[over.match.list]/1比较{{"test"}} -> const char*{{"test"}} -> std::string(请注意最外层的大括号被省略)。
当非聚合类类型T的对象被列表初始化时,[dcl.init.list]指定根据本子句中的规则执行重载决议,重载决议分两个阶段选择构造函数: - 首先,候选函数是类T的初始化器列表构造函数([dcl.init.list])... - 如果找不到可行的初始化器列表构造函数,则再次执行重载决议,其中候选函数是类T的所有构造函数,参数列表由初始化器列表的元素组成。
...在复制列表初始化中,如果选择了显式构造函数,则初始化无效。 注意,这里考虑了所有构造函数,而不管指定符号explicit。 {{"test"}} -> const char*根据[over.ics.list]/10[over.ics.list]/11,不存在。
否则,如果参数类型不是一个类:
  • 如果初始化列表有一个元素它本身不是一个初始化列表...
  • 如果初始化列表没有元素...
在上述未列举的所有情况下,都无法进行转换。
要确定{{"test"}} ->std::string,需要采取同样的过程,并且重载解析选择接受类型为const char*std::string构造函数。
因此,{{{"test"}}} -> A通过选择构造函数A(std::string)来完成。

变量

如果删除explicit会发生什么?

过程不会改变。GCC将选择构造函数 A(const char*),而Clang将选择构造函数 A(std::string)。我认为这是GCC的一个错误。

如果在b1的初始化器中只有两层大括号会怎样?

请注意,{{"test"}} -> const char*不存在,但{"test"} -> const char*存在。因此,如果在b1的初始化器中只有两层大括号,则会选择构造函数A(const char*),因为{"test"} -> const char*{"test"} -> std::string更好。结果,在复制列表初始化(从{"test"}初始化参数A的构造函数B(A))中选择了显式构造函数,然后程序不正确。

如果声明了构造函数 B(const B&) 会发生什么?

请注意,如果移除 B(B&) 的声明也会发生这种情况。这次我们需要比较 {{{"test"}}} -> A{{{"test"}}} -> const B& 或等价的 {{{"test"}}} -> const B

为了确定 {{{"test"}}} -> const B,采用上述描述的过程。我们需要比较 {{"test"}} -> A{{"test"}} -> const B&。请注意,根据 [over.best.ics]/4{{"test"}} -> const B& 不存在。

然而,如果目标是构造函数的第一个参数或用户定义转换函数的隐式对象参数,并且构造函数或用户定义转换函数是以下情况之一的候选函数: - 当参数是类复制初始化的第二步中的临时变量时,根据[over.match.ctor]; - 根据[over.match.copy]、[over.match.conv]或[over.match.ref](在所有情况下); - 在初始化器列表恰好有一个元素本身就是初始化器列表的情况下,根据[over.match.list]的第二阶段,且目标是类X的构造函数的第一个参数,且转换为X或对cv X的引用。 则不考虑用户定义转换序列。
为了确定{{"test"}} -> A,需要再次执行上述过程。这与我们在前面小节中讨论的情况几乎相同。因此,选择构造函数A(const char*)。请注意,在此处选择构造函数以确定{{{"test"}}} -> const B,但实际上并不适用。尽管构造函数是显式的,但仍然允许这种情况发生。
因此,通过选择构造函数B(A),然后选择构造函数A(const char*),完成了{{{"test"}}} -> const B。现在,{{{"test"}}} -> A{{{"test"}}} -> const B都是用户定义的转换序列,两者都没有更好的。因此,初始化b1是有歧义的。

如果括号被花括号替换会怎样?

根据前一小节中引用的 [over.best.ics]/4,用户自定义转换 {{{"test"}}} -> const B& 不被考虑。因此,即使声明了构造函数B(const B&),结果与主要示例相同。

1

B b1({{{"test"}}}); 相当于 B b1(A{std::string{const char*[1]{"test"}}});

16.3.3.1.5 列表初始化序列 [over.ics.list]

4 如果参数类型是字符数组133并且初始化列表有一个单独的元素,该元素是适当类型的字符串字面值(11.6.2),则隐式转换序列为标识转换。

编译器尝试所有可能的隐式转换。 例如,如果我们有以下构造函数的类C:

#include <string>

struct C
{
    template<typename T, size_t N>     C(const T* (&&) [N]) {}
    template<typename T, size_t N>     C(const T  (&&) [N]) {}
    template<typename T=char>         C(const T* (&&)) {}
    template<typename T=char>          C(std::initializer_list<char>&&) {}
};

struct A
{
    explicit A(const char *) {}

    A(C ) {}
};

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

int main( void )
{
    const char* p{"test"};
    const char p2[5]{"test"};

    B b1({{{"test"}}});
}

clang 5.0.0编译器无法决定使用哪个并出现以下错误:
29 : <source>:29:11: error: call to constructor of 'C' is ambiguous
    B b1({{{"test"}}});
          ^~~~~~~~~~
5 : <source>:5:40: note: candidate constructor [with T = char, N = 1]
    template<typename T, size_t N>     C(const T* (&&) [N]) {}
                                       ^
6 : <source>:6:40: note: candidate constructor [with T = const char *, N = 1]
    template<typename T, size_t N>     C(const T  (&&) [N]) {}
                                       ^
7 : <source>:7:39: note: candidate constructor [with T = char]
    template<typename T=char>         C(const T* (&&)) {}
                                      ^
15 : <source>:15:9: note: passing argument to parameter here
    A(C ) {}
        ^

但如果我们只保留非初始化列表构造函数,代码就能编译通过。

GCC 7.2会选择 C(const T*(&&)) {} 并进行编译。如果不可用,则选择 C(const T*(&&)[N])

MSVC则会出现错误:

29 : <source>(29): error C2664: 'B::B(B &)': cannot convert argument 1 from 'initializer list' to 'A'

是的,警告很可能是关于大括号省略的问题。但是一旦嵌套了大括号,大括号省略就不再被允许,只有当a是标量时才可以使用。因此,在这种情况下,该警告是错误的。一旦添加更多的大括号,您必然会调用链中非标量类型之一的复制构造函数。 - Ext3h

0

(编辑,感谢 @dyp)

这是一个部分答案和推测,解释了我如何解释发生的事情,因为我不是编译专家,也不是很懂C++。

首先我会用一些直觉和常识。显然,最后发生的事情是一个B::B(A),因为这是B b1可用的唯一构造函数(显然不能是B::B(B&&),因为至少定义了一个复制构造函数,所以B::B(B&&)对我们不是隐式定义的)。此外,发生的第一个A或B的构建不能是A::A(const char*),因为它是显式的,所以必须使用A::A(std::string)。另外,最内层的引号文本是一个const char[5]。所以我猜测第一个、最内层的构造是一个const char*;然后是一个字符串构造:std::string::string(const char *)。还有一个花括号的构造,我猜它是A::A(A&&)(或者也许是A::A(A&)?)。因此,总结我的直觉猜测,构建顺序应该是:

  1. 一个 const char*
  2. 一个 std::string(真正的 std::basic_string<whatever>
  3. 一个 A
  4. 一个 B

然后我把这个代码放在 GodBolt上,并以GCC作为第一个示例。(或者,您可以在保留汇编语言输出的同时自己编译它,并通过c++filt进行更可读的处理)。这里是所有特别提到C++代码的行:

call   4006a0 <std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(char const*, std::allocator<char> const&)@plt>
call   400858 <A::A(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>
call   400868 <B::B(A)>
call   400680 <std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string()@plt>
call   400690 <std::allocator<char>::~allocator()@plt>
call   400690 <std::allocator<char>::~allocator()@plt>

看起来我们看到的适当可执行结构的顺序是:

(没有看到1.) 2. std::basic_string::basic_string(const char* /* 忽略分配器 */) 3. A::A(std::string) 4. B::B(A)

使用clang 5.0.0,结果类似于IIANM,至于MSVC - 谁知道呢?也许这是一个错误?他们已经被认为有时在完全支持语言标准方面有些棘手。抱歉,就像我说的 - 部分答案。


1
或者呢?B::B(B&&)怎么办? 我记得这个不存在,因为用户声明了复制构造函数。 - dyp
1
从clang的警告来看,我的猜测是最内层的{}正在初始化char const* - dyp
2
(而且用户声明的复制构造函数不能绑定到rvalue,因为它是一个非const引用,因此B(B const&)再次不明确) - dyp
@dyp:你说得对。最内层的引用文本是一个const char[5]。我会进行编辑。 - einpoklum
@dyp,你可能是对的,关于用户声明的复制构造函数,即使删除const和非const的复制构造函数之间存在语义差异,这听起来也不太对。同样地,一个被删除的函数仍然可能是模棱两可的。 - Ext3h
@Ext3h 将函数标记为= delete并不会删除该函数。这种误解是C++中关键字重用的另一个不幸后果。相反,= delete将函数标记为无效,即尝试调用此类函数是无效的;更具体地说,如果重载决议产生这样的函数作为(最佳)结果,则程序是非法的。 - dyp

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接