为什么在使用花括号初始化列表时,std::initializer_list构造函数更受青睐?

38
考虑一下这段代码
#include <iostream>

class Foo
{
    int val_;
public:
    Foo(std::initializer_list<Foo> il)
    {
        std::cout << "initializer_list ctor" << std::endl;
    }
    /* explicit */ Foo(int val): val_(val)
    {
        std::cout << "ctor" << std::endl;
    };
};

int main(int argc, char const *argv[])
{
    // why is the initializer_list ctor invoked?
    Foo foo {10}; 
}

输出是
ctor
initializer_list ctor

据我理解,值10被隐式转换为Foo(第一个ctor输出),然后初始化构造函数开始执行(第二个initializer_list ctor输出)。我的问题是为什么会发生这种情况?难道标准构造函数Foo(int)不是更好的匹配吗?也就是说,我本来期望这段代码的输出只有ctor
附注:如果我将构造函数Foo(int)标记为explicit,那么只会调用Foo(int)构造函数,因为整数10现在无法隐式转换为Foo

我知道它比普通构造函数更好,但我不知道即使普通构造函数更匹配时它也更好。是有什么特别的原因吗?这样做可以隐藏复制构造函数(实际上,我所拥有的代码将隐藏复制构造函数,不是吗?) - vsoftco
2
Scott Meyers的新书《Effective Modern C++》中有一个非常好的项目,关于各种初始化方式:“项目7:在创建对象时区分()和{}”。它没有提供行为背后的原理,但详细介绍了一些可能让你感到惊讶的边缘案例。 - Michael Burr
@MichaelBurr 谢谢,我还在等待实体复印件 :) - vsoftco
我知道这与主题无关,但有人能告诉我在我的构造函数中,我应该按值还是按const引用传递initializer_list?原因是什么? - 0xB00B
3个回答

30

§13.3.1.7 [over.match.list]/p1:

当非聚合类类型 T 的对象进行列表初始化(8.5.4)时,重载决议会分两个阶段选择构造函数:

  • 最初的候选函数是类 T 的初始化器列表构造函数(8.5.4),参数列表由初始化器列表作为单个参数组成。
  • 如果没有可行的初始化器列表构造函数,则再次进行重载决议,其中候选函数是类 T 的所有构造函数,参数列表由初始化器列表的元素组成。

如果初始化器列表没有元素,并且 T 有一个默认构造函数,则省略第一阶段。在复制列表初始化中,如果选择了一个 explicit 构造函数,则初始化是不合法的。

只要有可行的初始化器列表构造函数,在使用列表初始化且初始化器列表至少有一个元素时,它将优先于所有非初始化器列表构造函数。


2
好的,+1,我认为这个解决了!出于某种原因,我认为如果需要转换,init_list构造函数不会优先于其他构造函数,但事实并非如此。但是,如果是这种情况,那么我现在的代码似乎隐藏了一个潜在的复制构造函数,对吗?因为复制构造函数只会复制init_list构造函数的参数,而后者可以执行任何我想要的操作。 - vsoftco
@vsoftco 嗯,这个规则只适用于使用列表初始化的情况。如果您不使用 {},那么复制构造函数也会正常调用。一个隐式特殊成员函数确实可以被“隐藏”。一个常见的情况是一个模板构造函数,它采用一个单一的转发引用参数,后面可以选择跟随一个包(例如, template <class T> Foo(T&&)),通常比采用 const Foo& 的复制构造函数更合适。 - T.C.
@vsoftco,你的代码只是隐藏了一个潜在的副本,因为你有一个Foo(std::initializer_list<Foo>)构造函数,这对我来说没有意义。那个构造函数是干什么用的?无论如何,如果你使用一个非空的花括号初始化列表,并且有一个初始化列表构造函数,它将被使用,即使需要创建一个Foo对象数组来绑定std::initializer_list - Jonathan Wakely
1
@JonathanWakely 这只是一个玩具示例,因为我试图理解std::initializer_list构造函数的工作原理。我在现实中不使用它 :) - vsoftco

15

n2100提案对初始化列表进行了详细的讨论,决定将序列构造函数(即接受std::initializer_lists的构造函数)优先于常规构造函数。请参见附录B进行详细讨论。简而言之,在结论中概括如下:

11.4 结论
那么,我们如何在剩下的两个选择(“歧义”和“序列构造函数优先于普通构造函数”)之间做出决定呢?我们的建议是给序列构造函数优先级,因为:
- 在所有构造函数中寻找歧义会导致太多的“误报”,即看似不相关的构造函数之间的冲突。请参见下面的示例。 - 消除歧义本身就容易出错(以及冗长)。请参见第11.3节中的示例。 - 对于具有同质列表元素数量的每个列表,使用完全相同的语法很重要 - 应该对普通构造函数进行消歧义(它们没有规则的参数模式)。请参见第11.3节中的示例。最简单的误报示例是默认构造函数:
vector<int> v;
vector<int> v { }; // 可能存在歧义
void f(vector<int>&);
// ...
f({ }); // 可能存在歧义

虽然有可能想到一些类,在这些类中,没有成员的初始化在语义上与默认初始化不同,但我们不会为了更好地支持这些情况而使语言变得复杂,而是为了更常见的情况提供更好的支持,其中它们在语义上是相同的。
将序列构造函数优先级放在首位可以将参数检查分解为更易理解的块,并提供更好的局部性。
void f(const vector<double>&);
// ...
struct X { X(int); /* ... */ };
void f(X);
// ...
f(1);     // 调用f(X);vector的构造函数是显式的
f({1});   // 可能存在歧义:X还是vector?
f({1,2}); // 可能存在歧义:vector的1个元素还是2个元素

在这里,将序列构造函数放在首位消除了来自X的干扰。选择X作为f(1)的问题是§3.3中显示的显式问题的变体。

5
整个初始化列表的概念是为了实现以下方式的列表初始化: ```

整个初始化列表的概念是为了实现以下方式的列表初始化:

```
std::vector<int> v { 0, 1, 2 };

考虑这种情况:
std::vector<int> v { 123 };

这个初始化向量的方式是有意为之的,它用一个值为123的元素来初始化向量,而不是用值为零的123个元素。

要使用另一个构造函数,请使用旧语法。

Foo foo(10);

3
这是有道理的,因为initializer_list和普通构造函数都接受int作为参数。令人惊讶的是,即使在initializer_list构造函数中需要进行转换,后者仍然更受青睐。 - vsoftco
@seldon,“旧语法”是唯一从initializer_list中逃脱的方式吗?我的意思是,这是标准的方式吗? - gedamial

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