C++列表初始化详解(新手必看)
在 C++11 中,花括号初始化是数据初始化的统一方法,被称为列表初始化或者统一初始化。它可以说是 C++11 中开发人员应该理解和使用的重要特性之一。它屏蔽了之前基本类型、聚合类型和非聚合类型,以及数组和标准容器初始化的差异。
为了更好地理解本节的内容,读者先要掌握直接初始化(它根据一组显式的构造函数参数初始化对象)和复制初始化(它根据另一个对象初始化一个对象)方法。以下是两种初始化方法的简单示例:
当采用花括号初始化形式时,它们被称为直接列表初始化和复制列表初始化:
下面是一些列表初始化示例:
1) 对于标准容器:
2) 对于动态数组:
3) 对于内置类型:
4) 对于用户自定义类型:
5) 对于用户自定义 POD 类型:
1) 基本类型通过赋值进行初始化:
2) 如果类中有转换构造函数,类对象也可以使用一个值通过赋值来初始化(在 C++11 之前,带有单个参数的构造函数称为转换构造函数):
3) 当提供参数时,非聚合类可以用圆括号(函数形式)初始化,并且只有在执行默认初始化时才不带圆括号(调用默认构造函数)。
在下面这个例子中,foo 是前文中定义的结构:
4) 聚合类型和 POD 类型可以用花括号初始化形式初始化。在下面的例子中,bar 是前文定义的结构:
除了初始化数据的方法不同之外,还有一些限制。例如,初始化标准容器(除了复制构造函数)的唯一方法是先声明一个对象,然后将元素插入其中,但是 std::vector 是个例外,因为它可以通过数组赋值,而数组可以通过前面提到的聚合类初始化方法初始化。但是,动态分配的聚合类对象不能直接进行初始化。
前面的所有例子都用的是直接初始化方法,但是复制初始化也可以采用花括号初始化形式。直接初始化和复制初始化在某些情况下是等价的,但是复制初始化不太灵活,因为它在其隐式转换过程中不考虑 explicit 构造函数,必须从初始化列表中产生对象,而直接初始化期望从初始化列表到构造函数的参数的隐式转换。因此,动态分配数组只能通过直接初始化方法进行初始化。
在前面示例展示的类定义中,foo 是一个既有默认构造函数又有带参数的构造函数的类。用默认构造函数进行默认初始化,我们需要用空的花括号,即 {}。用带参数的构造函数,我们必须在 {} 中提供相应参数的值。不像非聚合类类型,它们的默认初始化就是调用默认构造函数,对于聚合类型,默认初始化意味着用 0 值初始化。
可以初始化标准容器(例如 vector 和 map),因为所有标准容器在 C++11 中都有一个额外的构造函数,它接受类型为 std::initializer_list<T> 的参数。这基本上是对 T const 类型元素数组的轻量级代理。这些构造函数根据初始化列表中的值初始化内部数据。
使用 std::initializer_list 进行初始化的方式如下:
当使用花括号初始化时,初始化列表方式总是优先于其他构造函数。如果类中含有这样的构造函数,则当用到花括号初始化时会调用它:
这个优先级原则适用于所有的函数,而不仅仅是构造函数。在下面这个例子中,存在着两个签名相同的重载函数。使用初始化列表调用函数会解析为调用 std::initializer_list 重载版本:
另外需要注意的是,花括号初始化不允许收缩转换(narrowing conversion)。根据 C++ 标准,收缩转换是隐式转换:
以下声明会导致编译失败,因为它们需要进行收缩转换:
我们来看另外一个例子:
C++17 修改了列表初始化的规则,区分了直接列表初始化和复制列表初始化。类型推导的新规则如下:
基于这些新规则,之前的示例将发生如下变化(注释中描述了推导出的类型):
为了更好地理解本节的内容,读者先要掌握直接初始化(它根据一组显式的构造函数参数初始化对象)和复制初始化(它根据另一个对象初始化一个对象)方法。以下是两种初始化方法的简单示例:
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 进行初始化的方式如下:
- 编译器解析初始化列表中元素的类型(所有元素必须具有相同的类型);
- 编译器使用初始化列表中的元素创建一个数组;
- 编译器创建一个 std::initializer_list<T> 对象来包装之前创建的数组;
- std::initializer_list<T> 被作为参数传递给构造函数。
当使用花括号初始化时,初始化列表方式总是优先于其他构造函数。如果类中含有这样的构造函数,则当用到花括号初始化时会调用它:
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++ 标准,收缩转换是隐式转换:
- 从浮点类型到整型的转换;
- 从 long double 到 double 或 float,或者从 double 到 float,除非源是一个常量表达式,并且转换后的实际值在可以表示的值范围内(即使丢失精度);
- 从整型或无作用域枚举到浮点类型,除非源是常量表达式,并且转换后的实际值适配目标类型且再转换为原始类型时可以得到原始的值;
- 从整型或无作用域枚举到更小范围的整型(不能完全表示原始类型的所有值),除非源是常量表达式,并且转换后的实际值适配目标类型且再转换为原始类型时可以得到原始的值。
以下声明会导致编译失败,因为它们需要进行收缩转换:
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 会被推导为 std::initializer_list<T>,否则推导格式错误。
- 对于直接列表初始化,如果列表中只有一个元素,auto 会被推导为 T,如果有不止一个元素,则推导格式错误。
基于这些新规则,之前的示例将发生如下变化(注释中描述了推导出的类型):
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 用的是直接列表初始化而且初始化列表不止一个元素,所以导致编译失败。