首页 > 编程笔记 > C++笔记 阅读:33

C++列表初始化详解(新手必看)

C++11 中,花括号初始化是数据初始化的统一方法,被称为列表初始化或者统一初始化。它可以说是 C++11 中开发人员应该理解和使用的重要特性之一。它屏蔽了之前基本类型、聚合类型和非聚合类型,以及数组和标准容器初始化的差异。

为了更好地理解本节的内容,读者先要掌握直接初始化(它根据一组显式的构造函数参数初始化对象)和复制初始化(它根据另一个对象初始化一个对象)方法。以下是两种初始化方法的简单示例:
std::string s1("test");  // direct initialization
std::string s2 = "test"; // copy initialization
接下来,我们一起探究一下如何执行列表初始化。

C++ 列表初始化使用方式

要无差别地初始化对象,就用花括号初始化形式,即 { },对于直接初始化和复制初始化,它都适用。

当采用花括号初始化形式时,它们被称为直接列表初始化和复制列表初始化:
T object {other};  // direct-list-initialization
T object = {other}; // copy-list-initialization

下面是一些列表初始化示例:
1) 对于标准容器:
std::vector<int> v { 1, 2, 2 };
std::map<int, std::string> m { { 1, "one"}, { 2, "two" } };

2) 对于动态数组:
int arr2 = new int[3]{ 1, 2, 3 };

3) 对于内置类型:
int i { 42 };
double d { 1.2 };

4) 对于用户自定义类型:
class foo
{
    int a_;
    double b_;
public:
    foo():a_(0), b_(0) {}
    foo(int a, double b = 0.0):a_(a), b_(b) {}
};

foo f1{};
foo f2{ 42, 1.2 };
foo f3{ 42 };

5) 对于用户自定义 POD 类型:
struct bar { int a_; double b_; };
bar b{ 42, 1.2 };

C++列表初始化工作原理

在 C++11 之前,对象的初始化取决于它们的类型:

1) 基本类型通过赋值进行初始化:
int a = 42;
double b = 1.2;

2) 如果类中有转换构造函数,类对象也可以使用一个值通过赋值来初始化(在 C++11 之前,带有单个参数的构造函数称为转换构造函数):
class foo
{
    int a_;
public:
    foo(int a):a_(a) {}
};
foo f1 = 42;

3) 当提供参数时,非聚合类可以用圆括号(函数形式)初始化,并且只有在执行默认初始化时才不带圆括号(调用默认构造函数)。

在下面这个例子中,foo 是前文中定义的结构:
foo f1;          // default initialization
foo f2(42, 1.2);
foo f3(42);
foo f4();       // function declaration

4) 聚合类型和 POD 类型可以用花括号初始化形式初始化。在下面的例子中,bar 是前文定义的结构:
bar b = {42, 1.2};
int a[] = {1, 2, 3, 4, 5};

除了初始化数据的方法不同之外,还有一些限制。例如,初始化标准容器(除了复制构造函数)的唯一方法是先声明一个对象,然后将元素插入其中,但是 std::vector 是个例外,因为它可以通过数组赋值,而数组可以通过前面提到的聚合类初始化方法初始化。但是,动态分配的聚合类对象不能直接进行初始化。

前面的所有例子都用的是直接初始化方法,但是复制初始化也可以采用花括号初始化形式。直接初始化和复制初始化在某些情况下是等价的,但是复制初始化不太灵活,因为它在其隐式转换过程中不考虑 explicit 构造函数,必须从初始化列表中产生对象,而直接初始化期望从初始化列表到构造函数的参数的隐式转换。因此,动态分配数组只能通过直接初始化方法进行初始化。

在前面示例展示的类定义中,foo 是一个既有默认构造函数又有带参数的构造函数的类。用默认构造函数进行默认初始化,我们需要用空的花括号,即 {}。用带参数的构造函数,我们必须在 {} 中提供相应参数的值。不像非聚合类类型,它们的默认初始化就是调用默认构造函数,对于聚合类型,默认初始化意味着用 0 值初始化。

可以初始化标准容器(例如 vector 和 map),因为所有标准容器在 C++11 中都有一个额外的构造函数,它接受类型为 std::initializer_list<T> 的参数。这基本上是对 T const 类型元素数组的轻量级代理。这些构造函数根据初始化列表中的值初始化内部数据。

使用 std::initializer_list 进行初始化的方式如下:
当使用花括号初始化时,初始化列表方式总是优先于其他构造函数。如果类中含有这样的构造函数,则当用到花括号初始化时会调用它:
class foo
{
    int a_;
    int b_;
public:
    foo():a_(0), b_(0) {}
    foo(int a):a_(a), b_(0) {}
    foo(std::initializer_list<int> l) {}
};

foo f(1,2); // calls constructor with initializer_list<int>

这个优先级原则适用于所有的函数,而不仅仅是构造函数。在下面这个例子中,存在着两个签名相同的重载函数。使用初始化列表调用函数会解析为调用 std::initializer_list 重载版本:
void func(int const a, int const b, int const c)
{
    std::cout << a << b << c << '\n';
}

void func(std::initializer_list<int> const list)
{
    for (auto const & e : list)
    std::cout << e << '\n';
}

func({ 1,2,3 }); // calls second overload
然而,这样也可能会导致 bug。以 std::vector 类型为例,在 vector 的构造函数中,有一个单参数(表示初始分配的元素个数)的构造函数,还有一个接受 std::initializer_list 作为参数的构造函数。如果我们的目的是创建一个有预分配个数元素的 vector,那么花括号初始化将无效,因为使用花括号初始化时,以s td::initializer_list 为参数的构造函数会优先被调用:
std::vector<int> v {5};
上述代码不会创建一个有 5 个元素的 vector,而是创建一个只有一个值为 5 的元素的 vector。为了能够真正创建有 5 个元素的 vector,必须使用圆括号的初始化方式:
std::vector<int> v(5);

另外需要注意的是,花括号初始化不允许收缩转换(narrowing conversion)。根据 C++ 标准,收缩转换是隐式转换:
以下声明会导致编译失败,因为它们需要进行收缩转换:
int i { 1.2 };

double d = 47 / 13;
float f1 { static_cast<float>(d) };

我们来看另外一个例子:
float f2(47/13);
上述声明是正确的,因为有一个从 int 到 float 的隐式转换。表达式 47/13 的值是 3,它被赋给 float 类型的变量 f2。

C++列表初始化的更多细节

以下示例展示了直接列表初始化和复制列表初始化。在 C++11 中,所有这些表达式的推导类型均为 std::initializer_list<int>:
auto a = {42};   // std::initializer_list<int>
auto b {42};   // std::initializer_list<int>
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2}; // std::initializer_list<int>

C++17 修改了列表初始化的规则,区分了直接列表初始化和复制列表初始化。类型推导的新规则如下:
基于这些新规则,之前的示例将发生如下变化(注释中描述了推导出的类型):
auto a = {42};   // std::initializer_list<int>
auto b {42};   // int
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2}; // error, too many
在这个示例中,a 和 c 被推导为 std::initializer_list<int>,b 被推导为 int,d 用的是直接列表初始化而且初始化列表不止一个元素,所以导致编译失败。

相关文章