现代C++ 7 初始化
文章目录
- 初始化 (C++)
- 1. **初始化器语法**
- 示例
- 2. **初始化语义**
- 示例
- 3. **非局部变量的初始化**
- 3.1 **静态初始化**
- 3.2 **动态初始化**
- 3.3 **早期动态初始化**
- 3.4 **延迟动态初始化**
- 示例
- 4. **静态局部变量的初始化**
- 示例
- 5. **类成员的初始化**
- 6. **注释**
- 7. **总结**
- 默认初始化 (Default Initialization) in C++
- 1. **语法**
- 2. **默认初始化的执行场景**
- 3. **默认初始化的效果**
- 4. **特殊情况**
- 5. **不确定的值和错误的值**
- 6. **示例**
- 示例 1:动态存储持续时间的对象
- 示例 2:自动存储持续时间的对象
- 示例 3:静态存储持续时间的对象
- 7. **总结**
- 值初始化 (Value Initialization) in C++
- 1. **语法**
- 2. **值初始化的执行场景**
- 3. **值初始化的效果**
- 4. **注意事项**
- 5. **示例**
- 6. **输出**
- 7. **总结**
- 复制初始化 (Copy Initialization) in C++
- 1. **语法**
- 2. **复制初始化的执行场景**
- 3. **复制初始化的效果**
- 4. **注意事项**
- 5. **示例**
- 6. **总结**
- 直接初始化的语法形式
- 直接初始化的效果
- 示例代码解析
- 聚合初始化的语法
- 聚合的定义
- 初始化过程
- 示例代码解析
- 示例代码解析
- 输出
- 关键点解释
- 功能测试宏
- C++ 列表初始化 (List-initialization) 概述
- 语法
- 直接列表初始化
- 拷贝列表初始化
- 初始化过程
- `std::initializer_list` 的构造
- 示例代码解析
- 输出
- 关键点解释
- 总结
- 收窄转换 (Narrowing Conversions) 概述
- 初始化顺序
- 类型推导和重载解析
- 示例代码解析
- 输出
- 关键点解释
- 总结
- C++ 引用初始化概述
- 语法
- 初始化规则
- 直接绑定
- 间接绑定
- 临时对象生命周期
- 示例代码解析
- 关键点解释
- 总结
初始化 (C++)
初始化是 C++ 中一个非常重要的概念,它涉及到在对象构造时为其提供初始值。C++ 提供了多种初始化方式,适用于不同的场景和对象类型。以下是关于 C++ 初始化的详细总结。
1. 初始化器语法
初始化器可以出现在变量声明或 new
表达式中,也可以用于函数参数和返回值的初始化。初始化器有以下几种形式:
-
复制初始化(Copy Initialization):
T object = expression;
- 使用
=
操作符进行初始化。 - 对于类类型,调用拷贝构造函数或移动构造函数(如果
expression
是右值)。 - 对于非类类型,直接赋值。
- 使用
-
直接初始化(Direct Initialization):
T object(expression);
- 使用圆括号进行初始化。
- 对于类类型,调用构造函数。
- 对于非类类型,直接构造。
-
列表初始化(List Initialization)(自 C++11 起):
T object{initializer_list};
- 使用大括号
{}
进行初始化。 - 对于聚合类型(如数组、结构体),按顺序初始化每个元素。
- 对于类类型,调用接受
std::initializer_list
的构造函数,或者逐个匹配构造函数参数。 - 如果没有合适的构造函数,可能会进行直接初始化或复制初始化。
- 列表初始化可以防止窄化转换(narrowing conversions),编译器会在这种情况下发出错误。
- 使用大括号
-
指定初始化列表(Designated Initializer List)(自 C++20 起):
T object{.member1 = value1, .member2 = value2};
- 允许为类成员指定初始化值,类似于 C 结构体的指定初始化。
- 提高了代码的可读性和灵活性。
示例
#include <string>
// 复制初始化
std::string s1 = "hello"; // copy-initialization
// 直接初始化
std::string s2("world"); // direct-initialization
// 列表初始化
std::string s3{'h', 'e', 'l', 'l', 'o'}; // list-initialization
// 指定初始化列表(C++20)
struct Point {
int x;
int y;
};
Point p{.x = 1, .y = 2}; // designated initializer list
2. 初始化语义
根据初始化器的不同,初始化语义也有所不同:
-
引用初始化:引用必须在声明时进行初始化,并且不能为
nullptr
。int a = 42; int& ref = a; // reference initialization
-
聚合初始化(Aggregate Initialization):适用于聚合类型(如数组、结构体、类),按顺序初始化每个成员。
struct S { int x; double y; }; S s1{1, 2.5}; // aggregate initialization
-
默认初始化:如果未提供初始化器,默认初始化将根据类型进行处理:
- 对于标量类型(如
int
、double
),不进行初始化,值是未定义的。 - 对于类类型,调用默认构造函数。
- 对于静态或线程本地存储期限的变量,进行零初始化(即所有字节设为 0)。
- 对于标量类型(如
-
值初始化:使用空括号
()
或空大括号{}
进行初始化:- 对于标量类型,初始化为 0。
- 对于类类型,调用默认构造函数(如果存在),否则进行零初始化。
- 对于静态或线程本地存储期限的变量,进行零初始化。
示例
int x; // default-initialization: x is uninitialized
int y{}; // value-initialization: y is initialized to 0
int z(); // NOT an initialization! This declares a function.
3. 非局部变量的初始化
非局部变量是指具有静态存储期限或线程本地存储期限的变量。它们的初始化分为两个阶段:静态初始化 和 动态初始化。
3.1 静态初始化
静态初始化有两种形式:
-
常量初始化:如果可以在编译时确定变量的值,则进行常量初始化。编译器会将这些值直接嵌入到程序映像中。
constexpr int x = 42; // constant initialization
-
零初始化:如果不能进行常量初始化,则非局部静态和线程本地变量会被零初始化(即所有字节设为 0)。零初始化的变量被放置在
.bss
段中,该段在磁盘上不占用空间,加载时由操作系统清零。
3.2 动态初始化
动态初始化发生在所有静态初始化之后,分为三种类型:
-
无序动态初始化:适用于类模板静态数据成员和变量模板(C++14 起),除非是显式特化。这些变量的初始化顺序是不确定的,除非程序在变量初始化之前启动了线程(C++17 起),在这种情况下它们的初始化是不排序的。
-
部分有序动态初始化(C++17 起):适用于所有不是隐式或显式实例化的内联变量。如果一个变量
V
在每个翻译单元中都定义在另一个变量W
之前,则V
的初始化在W
之前进行。 -
有序动态初始化:适用于所有其他非局部变量。在一个翻译单元内,这些变量的初始化顺序与它们在源代码中出现的顺序相同。不同翻译单元中的静态变量初始化顺序是随机的,而线程本地变量的初始化顺序是不排序的。
3.3 早期动态初始化
如果满足以下条件,编译器可以将动态初始化的变量提升为静态初始化:
- 初始化的动态版本不会在初始化之前更改任何其他命名空间范围对象的值。
- 静态初始化的结果与动态初始化的结果相同。
3.4 延迟动态初始化
动态初始化是否发生在 main
函数或线程的初始函数的第一个语句之前,还是延迟到之后,这是实现定义的。如果初始化被延迟,它会在同一个翻译单元中定义的任何具有静态或线程存储期限的变量的第一个 ODR 使用之前发生。如果某个翻译单元中的变量从未被使用,它们可能永远不会被初始化。
示例
// File 1
#include "a.h"
#include "b.h"
B b;
A::A() { b.Use(); }
// File 2
#include "a.h"
A a;
// File 3
#include "a.h"
#include "b.h"
extern A a;
extern B b;
int main() {
a.Use();
b.Use();
}
在这个例子中,a
和 b
的初始化顺序是不确定的。如果 a
在 main
之前初始化,b
可能还没有初始化,导致未定义行为。如果 a
的初始化被延迟到 main
之后,b
将在 A::A()
中使用之前被初始化。
4. 静态局部变量的初始化
静态局部变量(即块作用域内的静态或线程本地变量)在第一次执行到其定义处时进行初始化。这种初始化是线程安全的,C++11 引入了“一次初始化”保证,确保即使多个线程同时到达该点,变量也只会被初始化一次。
示例
void foo() {
static int x = expensive_initialization(); // thread-safe initialization
// ...
}
5. 类成员的初始化
类的非静态数据成员可以通过两种方式进行初始化:
-
成员初始化列表(Member Initializer List):
class MyClass { public: MyClass(int a) : member(a) {} // member initialization list private: int member; };
-
默认成员初始化器(Default Member Initializer)(C++11 起):
class MyClass { public: MyClass() = default; private: int member = 42; // default member initializer };
6. 注释
- 非局部变量的销毁顺序:非局部变量的销毁顺序与它们的初始化顺序相反。静态变量在
std::exit
被调用时销毁,线程本地变量在对应的线程结束时销毁。 - ODR 使用:ODR(One Definition Rule)使用是指在程序中对某个实体的首次使用。对于非内联变量,如果它们的初始化被延迟,初始化将在首次 ODR 使用之前发生。
- 内联变量(C++17 起):内联变量允许在多个翻译单元中定义同一变量,而不会违反 ODR。它们的初始化顺序与其他非局部变量相同。
7. 总结
- 初始化器语法:C++ 提供了多种初始化方式,包括复制初始化、直接初始化、列表初始化和指定初始化列表(C++20)。
- 初始化语义:根据初始化器的不同,初始化语义也有所不同,涉及引用初始化、聚合初始化、默认初始化和值初始化等。
- 非局部变量的初始化:非局部变量的初始化分为静态初始化和动态初始化,动态初始化又分为无序、部分有序和有序三种类型。
- 静态局部变量的初始化:静态局部变量在第一次执行到其定义处时进行初始化,是线程安全的。
- 类成员的初始化:类的非静态数据成员可以通过成员初始化列表或默认成员初始化器进行初始化。
通过合理使用这些初始化方式,开发者可以编写更高效、更安全的代码,避免潜在的未定义行为和性能问题。
默认初始化 (Default Initialization) in C++
默认初始化是指在没有提供显式初始化器的情况下,编译器为对象提供的初始化方式。这种初始化适用于多种情况,包括自动、静态和线程局部存储持续时间的变量声明,动态分配的对象,以及类构造函数中未提及的基类或非静态数据成员。
1. 语法
T 对象; // 自动、静态或线程局部存储持续时间的变量声明
new T; // 动态存储持续时间的对象创建
2. 默认初始化的执行场景
默认初始化在以下三种情况下执行:
-
自动、静态或线程局部存储持续时间的变量声明:
- 当一个变量被声明但没有提供初始化器时。
-
动态存储持续时间的对象创建:
- 当通过
new
表达式创建对象但没有提供初始化器时。
- 当通过
-
类构造函数中未提及的基类或非静态数据成员:
- 当调用类的构造函数时,如果某些基类或非静态数据成员没有在初始化列表中提到,则这些成员将进行默认初始化。
3. 默认初始化的效果
默认初始化的具体效果取决于对象的类型 T
:
-
类类型(Class Type):
- 如果
T
是一个(可能带有cv
限定符的)类类型,则会考虑其构造函数,并针对空参数列表进行重载解析。选择的构造函数(通常是默认构造函数)将被调用,以提供新对象的初始值。
- 如果
-
数组类型(Array Type):
- 如果
T
是一个数组类型,则数组的每个元素都会被默认初始化。
- 如果
-
标量类型(Scalar Type):
- 如果
T
是一个标量类型(如int
、double
),则不会执行任何初始化,对象的值是不确定的(indeterminate)。对于具有自动或动态存储持续时间的标量类型,这会导致未定义行为(undefined behavior)。
- 如果
-
const
对象的默认初始化:- 如果程序要求对一个
const
限定类型的对象进行默认初始化,则该类型必须是const-default-constructible
的类类型或其数组。这意味着该类必须有一个可调用的默认构造函数,并且该构造函数不能抛出异常。
- 如果程序要求对一个
4. 特殊情况
-
const
对象的默认初始化:- 对于
const
限定的标量类型或类类型,如果它们没有提供显式的初始化器,则编译器会报错,因为const
对象必须在声明时初始化。
- 对于
-
联合体(Union):
- 对于联合体,默认初始化只会初始化第一个非静态数据成员,除非有成员提供了默认成员初始化器。对于
const
联合体,所有成员都必须是const-default-constructible
。
- 对于联合体,默认初始化只会初始化第一个非静态数据成员,除非有成员提供了默认成员初始化器。对于
-
匿名联合体(Anonymous Union):
- 对于匿名联合体,默认初始化只会初始化第一个非静态数据成员,除非有成员提供了默认成员初始化器。
5. 不确定的值和错误的值
-
不确定的值(Indeterminate Value):
- 当为具有自动或动态存储期的对象获取存储空间时,该对象的值是不确定的。如果未对对象进行初始化,则该对象会保留不确定的值,直到该值被替换。使用不确定的值会导致未定义行为。
-
错误的值(Erroneous Value)(自 C++26 起):
- 如果对象的字节具有错误的值(由实现定义),则该对象具有错误的值。使用错误的值会导致错误的行为(erroneous behavior)。
-
未初始化友好的类型(Uninitialized-Friendly Types):
- 以下类型被认为是未初始化友好的:
std::byte
(自 C++17 起)unsigned char
char
(如果其底层类型是unsigned char
)
- 对于这些类型,即使它们的值是不确定的或错误的,某些操作仍然是明确定义的,例如条件表达式的第二个或第三个操作数、逗号表达式的右操作数、简单赋值运算符的右操作数等。
- 以下类型被认为是未初始化友好的:
6. 示例
示例 1:动态存储持续时间的对象
int f(bool b) {
unsigned char* c = new unsigned char;
unsigned char d = *c; // OK, “d” has an indeterminate value
int e = d; // undefined behavior
return b ? d : 0; // undefined behavior if “b” is true
}
- 在这个例子中,
*c
的值是不确定的,因此d
也有不确定的值。将d
转换为int
会导致未定义行为。
示例 2:自动存储持续时间的对象
int g(bool b) {
unsigned char c; // “c” has an indeterminate/erroneous value
unsigned char d = c; // no undefined/erroneous behavior,
// but “d” has an indeterminate/erroneous value
assert(c == d); // holds, but both integral promotions have
// undefined/erroneous behavior
int e = d; // undefined/erroneous behavior
return b ? d : 0; // undefined/erroneous behavior if “b” is true
}
- 在这个例子中,
c
和d
都有不确定的值,尽管c == d
可能为真,但这并不意味着它们的值是确定的。将d
转换为int
会导致未定义行为。
示例 3:静态存储持续时间的对象
#include <string>
struct T1 { int mem; };
struct T2 {
int mem;
T2() {} // “mem” is not in the initializer list
};
int n; // static non-class, a two-phase initialization is done:
// 1) zero-initialization initializes n to zero
// 2) default-initialization does nothing, leaving n being zero
int main() {
[[maybe_unused]] int n; // non-class, the value is indeterminate
std::string s; // class, calls default constructor, the value is ""
std::string a[2]; // array, default-initializes the elements, the value is {"", ""}
// int& r; // Error: a reference
// const int n; // Error: a const non-class
// const T1 t1; // Error: const class with implicit default constructor
[[maybe_unused]] T1 t1; // class, calls implicit default constructor
const T2 t2; // const class, calls the user-provided default constructor
// t2.mem is default-initialized
}
n
是静态存储持续时间的非类类型,首先进行零初始化,然后进行默认初始化,最终n
的值为 0。s
是类类型std::string
,调用默认构造函数,初始值为空字符串""
。a
是数组类型,数组的每个元素都被默认初始化为std::string
的默认构造函数。t1
是类类型T1
,调用隐式默认构造函数。t2
是const
类类型T2
,调用用户提供的默认构造函数,t2.mem
进行默认初始化。
7. 总结
- 默认初始化:当没有提供显式初始化器时,C++ 会根据对象的类型进行默认初始化。对于类类型,调用默认构造函数;对于数组类型,每个元素进行默认初始化;对于标量类型,值是不确定的。
const
对象:const
对象必须在声明时初始化,否则会导致编译错误。- 不确定的值和错误的值:未初始化的标量类型对象具有不确定的值,使用这些值会导致未定义行为。自 C++26 起,某些对象的字节可能具有错误的值,使用这些值会导致错误的行为。
- 未初始化友好的类型:某些类型(如
std::byte
、unsigned char
)在处理不确定或错误的值时具有更宽松的行为规则。
通过理解默认初始化的行为,开发者可以避免潜在的未定义行为和性能问题,编写更安全、更可靠的代码。
值初始化 (Value Initialization) in C++
值初始化是 C++ 中一种特殊的初始化形式,它在对象使用空的初始化器(即空括号 ()
或空大括号 {}
)创建时执行。值初始化的主要目的是确保对象在创建时具有确定的初始值,避免未定义行为。
1. 语法
值初始化可以通过以下几种方式触发:
-
无名临时对象:
T(); // 使用空括号 T{}; // 使用空大括号 (C++11 起)
-
动态分配的对象:
new T(); // 使用空括号 new T{}; // 使用空大括号 (C++11 起)
-
类成员或基类的初始化:
Class::Class(...) : member() { ... } // 使用空括号 Class::Class(...) : member{} { ... } // 使用空大括号 (C++11 起)
-
命名对象的声明:
T object{}; // 使用空大括号 (C++11 起)
-
无名临时对象的值初始化:
T {}; // 使用空大括号 (C++11 起)
-
动态分配的对象的值初始化:
new T {}; // 使用空大括号 (C++11 起)
-
类成员或基类的值初始化:
Class::Class(...) : member{} { ... } // 使用空大括号 (C++11 起)
2. 值初始化的执行场景
值初始化在以下情况下执行:
- 无名临时对象:当使用空括号或空大括号创建无名临时对象时。
- 动态分配的对象:当使用
new
表达式并提供空括号或空大括号作为初始化器时。 - 类成员或基类的初始化:当在构造函数的成员初始化列表中使用空括号或空大括号初始化非静态数据成员或基类时。
- 命名对象的声明:当使用空大括号声明命名对象时(自动、静态或线程局部存储持续时间)。
3. 值初始化的效果
值初始化的具体效果取决于对象的类型 T
:
-
类类型(Class Type):
- 如果
T
是一个(可能带有cv
限定符的)类类型,并且默认初始化选择了构造函数,而该构造函数不是用户提供的(C++11 之前为用户声明的),则对象首先进行零初始化。 - 在任何情况下,对象都会进行默认初始化。
- 如果
-
数组类型(Array Type):
- 如果
T
是一个数组类型,则数组的每个元素都会进行值初始化。
- 如果
-
标量类型(Scalar Type):
- 如果
T
是一个标量类型(如int
、double
),则对象会进行零初始化,值为 0 或 0.0。
- 如果
-
聚合类型(Aggregate Type):
- 如果
T
是一个聚合类型,并且使用了空大括号{}
进行初始化,则会进行聚合初始化,而不是值初始化。聚合初始化会逐个初始化聚合体的每个成员。
- 如果
-
接受
std::initializer_list
的类:- 如果
T
是一个类类型,并且没有默认构造函数但有一个接受std::initializer_list
的构造函数,则会进行列表初始化。
- 如果
4. 注意事项
-
T object();
并不进行值初始化:- 语法
T object();
不是值初始化,而是声明了一个返回类型为T
的函数。要对命名变量进行值初始化,可以使用T object = T();
或T object{};
(C++11 起)。
- 语法
-
引用不能进行值初始化:
- 引用必须在声明时进行显式初始化,不能进行值初始化。
-
数组类型的值初始化:
- 语法
T()
不能用于数组类型,但T{}
可以。
- 语法
-
标准容器的行为:
- 所有标准容器(如
std::vector
、std::list
等)在使用单个size_type
参数构造或通过resize()
方法扩展时,会值初始化其元素,除非自定义的分配器改变了这种行为。
- 所有标准容器(如
5. 示例
#include <cassert>
#include <iostream>
#include <string>
#include <vector>
struct T1 {
int mem1;
std::string mem2;
virtual void foo() {} // 确保 T1 不是聚合类型
}; // 隐式默认构造函数
struct T2 {
int mem1;
std::string mem2;
T2(const T2&) {} // 用户提供的拷贝构造函数
}; // 没有默认构造函数
struct T3 {
int mem1;
std::string mem2;
T3() {} // 用户提供的默认构造函数
};
std::string s{}; // 类 => 默认初始化,值为 ""
int main() {
int n{}; // 标量 => 零初始化,值为 0
assert(n == 0);
double f = double(); // 标量 => 零初始化,值为 0.0
assert(f == 0.0);
int* a = new int[10](); // 数组 => 每个元素进行值初始化
assert(a[9] == 0); // 每个元素的值为 0
T1 t1{}; // 类,隐式默认构造函数 =>
assert(t1.mem1 == 0); // t1.mem1 进行零初始化,值为 0
assert(t1.mem2 == ""); // t1.mem2 进行默认初始化,值为 ""
// T2 t2{}; // 错误:没有默认构造函数
T3 t3{}; // 类,用户提供的默认构造函数 =>
std::cout << t3.mem1; // t3.mem1 进行默认初始化,值为不确定
assert(t3.mem2 == ""); // t3.mem2 进行默认初始化,值为 ""
std::vector<int> v(3); // 每个元素进行值初始化
assert(v[2] == 0); // 每个元素的值为 0
std::cout << '\n';
delete[] a;
}
6. 输出
42
7. 总结
- 值初始化:当对象使用空的初始化器(空括号
()
或空大括号{}
)创建时,C++ 会根据对象的类型进行值初始化。对于类类型,如果默认构造函数不是用户提供的,则首先进行零初始化,然后进行默认初始化;对于标量类型,进行零初始化;对于数组类型,每个元素进行值初始化。 - 聚合类型:如果类型是聚合类型,并且使用空大括号
{}
初始化,则会进行聚合初始化,而不是值初始化。 - 标准容器:所有标准容器在构造或扩展时会值初始化其元素,除非自定义分配器改变了这种行为。
- 注意事项:
T object();
并不进行值初始化,而是声明了一个函数;引用不能进行值初始化。
通过理解值初始化的行为,开发者可以确保对象在创建时具有确定的初始值,从而避免潜在的未定义行为和错误。
复制初始化 (Copy Initialization) in C++
复制初始化是 C++ 中一种常见的对象初始化方式,它通过一个现有的对象或表达式来初始化另一个对象。复制初始化使用赋值语法(=
),并且在某些情况下会调用构造函数、转换函数或标准转换来进行初始化。
1. 语法
复制初始化可以通过以下几种方式触发:
-
命名变量的声明:
T object = other; // 使用等号和表达式
-
标量类型的聚合初始化(C++11 之前):
T 对象 = {其他}; // 使用等号和花括号(C++11 之前)
-
按值传递参数给函数:
f(其他); // 按值传递参数
-
从按值返回的函数返回:
return 其他; // 从函数返回
-
抛出或捕获异常:
throw 对象; catch (T 对象) { ... } // 捕获异常
-
聚合初始化的一部分:
T 数组[N] = {其他序列}; // 初始化数组元素
2. 复制初始化的执行场景
复制初始化在以下情况下执行:
- 命名变量的声明:当使用等号和表达式声明非引用类型
T
的命名变量时。 - 标量类型的聚合初始化(C++11 之前):当使用等号和花括号声明标量类型
T
的命名变量时。 - 按值传递参数给函数:当按值传递参数给函数时。
- 从按值返回的函数返回:当从按值返回的函数返回时。
- 抛出或捕获异常:当抛出或捕获异常时。
- 聚合初始化的一部分:作为聚合初始化的一部分,初始化为其提供了初始化器的每个元素。
3. 复制初始化的效果
复制初始化的具体效果取决于对象的类型 T
和初始化器 other
的类型:
-
类类型(Class Type):
-
如果
T
是类类型,并且初始化器是一个cv
非限定类型与T
相同的纯右值表达式,则使用初始化器表达式本身(而不是由其物化的临时对象)来初始化目标对象。这被称为 复制省略(copy elision)。 -
否则,如果
T
是类类型,并且other
类型的cv
非限定版本是T
或从T
派生的类,则检查T
的非显式构造函数,并通过重载解析选择最佳匹配。然后调用该构造函数来初始化对象。 -
否则,如果
T
是类类型,并且other
类型的cv
非限定版本不是T
或从T
派生,或者如果T
是非类类型但other
的类型是类类型,则检查可以从other
的类型转换为T
(或者如果T
是类类型并且转换函数可用,则转换为从T
派生的类型)的用户定义的转换序列,并通过重载解析选择最佳的一个。转换的结果(如果使用了转换构造函数,则为T
的cv
非限定版本的纯右值临时对象)然后用于直接初始化对象。最后一步通常会被优化,转换结果直接在为目标对象分配的内存中构造,但即使不使用适当的构造函数(移动或复制),也需要可以访问它。
-
-
非类类型(Non-Class Type):
- 如果
T
和other
的类型都不是类类型,则在必要时使用标准转换将other
的值转换为T
的cv
非限定版本。
- 如果
4. 注意事项
-
显式构造函数:显式构造函数不是转换构造函数,在复制初始化中不予考虑。因此,如果
T
有一个显式构造函数,复制初始化将失败。struct Exp { explicit Exp(const char*) {} // 显式构造函数 }; Exp e1("abc"); // OK: 直接初始化 Exp e2 = "abc"; // Error: 复制初始化不考虑显式构造函数
-
隐式转换:复制初始化中的隐式转换必须直接从初始化器生成
T
,而不能通过多步转换。例如,直接初始化可以接受从初始化器到T
的构造函数的参数的隐式转换,但复制初始化不行。struct S { S(std::string) {} // 可以从 std::string 转换 }; S s("abc"); // OK: 从 const char[4] 转换为 std::string S s = "abc"; // Error: 无法从 const char[4] 转换为 S S s = "abc"s; // OK: 从 std::string 转换为 S
-
移动构造函数:如果
other
是一个右值表达式,则在复制初始化期间,将通过重载解析选择并调用移动构造函数。这仍然被视为复制初始化;对于这种情况,没有特殊的术语(例如,移动初始化)。 -
复制省略:在某些情况下,编译器可以优化掉不必要的临时对象创建,直接将初始化器的内容复制到目标对象中。这种优化称为复制省略(copy elision)。
-
赋值运算符无关:命名变量的复制初始化中的等号
=
与赋值运算符无关。赋值运算符重载对复制初始化没有影响。
5. 示例
#include <memory>
#include <string>
#include <utility>
struct A {
operator int() { return 12; }
};
struct B {
B(int) {}
};
int main() {
// 1. 复制初始化字符串
std::string s = "test"; // OK: 构造函数是非显式的
std::string s2 = std::move(s); // 这个复制初始化执行移动操作
// 2. 独占指针不能通过复制初始化
// std::unique_ptr<int> p = new int(1); // Error: 构造函数是显式的
std::unique_ptr<int> p(new int(1)); // OK: 直接初始化
// 3. 标准转换
int n = 3.14; // 浮点到整数的转换
const int b = n; // const 不影响
int c = b; // ...无论如何
// 4. 用户定义的转换
A a;
B b0 = 12;
// B b1 = a; // Error: 无法从 'A' 转换为非标量类型 'B'
B b2{a}; // OK: 调用 A::operator int(),然后调用 B::B(int)
B b3 = {a}; // OK: 同上
auto b4 = B{a}; // OK: 同上
// 5. 赋值运算符需要重载
// b0 = a; // Error: 需要赋值运算符重载
// 6. 使用 lambda 表达式假装这些变量被使用
[](...){}(c, b0, b3, b4);
}
6. 总结
- 复制初始化:通过等号
=
或按值传递参数、返回、抛出、捕获异常等方式进行的对象初始化。它会根据对象的类型选择合适的构造函数、转换函数或标准转换来初始化对象。 - 显式构造函数:显式构造函数不在复制初始化中考虑,因此会导致编译错误。
- 隐式转换:复制初始化中的隐式转换必须直接从初始化器生成目标类型,而不能通过多步转换。
- 移动构造函数:如果初始化器是右值表达式,复制初始化可能会调用移动构造函数。
- 复制省略:编译器可以在某些情况下优化掉不必要的临时对象创建,直接将初始化器的内容复制到目标对象中。
- 赋值运算符无关:复制初始化中的等号
=
与赋值运算符无关,赋值运算符重载对复制初始化没有影响。
通过理解复制初始化的行为,开发者可以更好地控制对象的初始化过程,避免潜在的编译错误和性能问题。
C++ 中的直接初始化是一种通过构造函数来创建和初始化对象的方式。它允许你使用参数列表来指定对象的初始状态,这些参数将被传递给类的构造函数。直接初始化可以用于各种类型的对象,包括基本数据类型、类类型和数组等。
直接初始化的语法形式
-
使用圆括号:
T object(arg);
或T object(arg1, arg2, ...);
- 适用于任何类型 T 的对象初始化。
-
使用大括号(自 C++11 起):
T object{arg};
或T object{arg1, arg2, ...};
- 对于非类类型,可以直接使用单个大括号进行初始化。对于类类型,这通常会调用与列表匹配的构造函数,但需要注意的是,如果构造函数是
explicit
,则不能隐式地通过列表初始化。
-
函数风格强制转换:
T(object)
或T(arg1, arg2, ...);
- 这种形式在 C++17 之后有了变化,特别是当 T 是一个类类型且初始化器是一个右值时,可能会直接使用该右值来初始化目标对象,而不是先创建临时对象再进行复制或移动。
-
静态转换:
static_cast<T>(object);
- 用于显式类型转换,也可以用来初始化对象。
-
动态内存分配:
new T(args, ...);
- 用于在堆上分配并初始化对象。
-
成员初始化列表:
Class::Class() : member(args, ...) { ... }
- 在定义构造函数时使用,用于初始化类的成员变量。
-
Lambda 表达式的捕获列表:
[arg]() { ... };
- 用于初始化 Lambda 表达式中的闭包对象成员。
直接初始化的效果
-
类类型:直接初始化会尝试找到一个与提供的参数相匹配的构造函数,并调用它来初始化对象。如果提供了
explicit
关键字,则不允许隐式转换,必须显式地调用构造函数。 -
非类类型:对于内置类型,如
int
、double
等,直接初始化会直接将参数转换为所需的类型,然后赋值给对象。 -
聚合类型:对于聚合类(即没有用户声明的构造函数、私有或受保护的成员、虚函数、基类或特定形式的默认成员初始化器的类),可以直接使用大括号列表进行初始化,但不允许窄化转换。
-
数组:直到 C++20,如果你尝试使用直接初始化来初始化数组,程序将是格式错误的。从 C++20 开始,数组可以通过聚合初始化进行初始化,但是隐式拷贝列表初始化可能不会选择
explicit
构造函数。
示例代码解析
#include <iostream>
#include <memory>
#include <string>
struct Foo
{
int mem;
explicit Foo(int n) : mem(n) {}
};
int main()
{
std::string s1("test"); // 使用 const char* 构造函数初始化字符串
std::string s2(10, 'a'); // 使用重复字符构造函数初始化字符串
std::unique_ptr<int> p(new int(1)); // OK: 显式构造函数允许使用 new 表达式
// std::unique_ptr<int> p = new int(1); // error: 构造函数是显式的,不允许隐式转换
Foo f(2); // 使用显式构造函数直接初始化 Foo 对象
// Foo f2 = 2; // error: 构造函数是显式的,不允许隐式转换
std::cout << s1 << ' ' << s2 << ' ' << *p << ' ' << f.mem << '\n';
}
这段代码展示了如何使用直接初始化来创建不同类型的对象,并说明了显式构造函数的作用。std::unique_ptr<int>
的例子表明,当构造函数是 explicit
时,你不能使用隐式转换(例如赋值操作)来创建对象,而必须使用直接初始化的形式。输出结果将会是:
test aaaaaaaaaa 1 2
这行输出表示所有对象都已成功初始化,并且按照预期打印了它们的值。
聚合初始化是 C++ 中一种特别的初始化形式,它允许我们使用初始化列表来初始化聚合类型的对象。聚合类型包括数组和特定条件下的类类型。这种初始化方式自 C++11 引入列表初始化后得到了增强,并且在 C++20 中进一步扩展了指定初始化器的功能。
聚合初始化的语法
-
普通初始化列表:
T 对象 = { arg1, arg2, ... };
T 对象 { arg1, arg2, ... };
(自 C++11 起)
-
指定初始化器 (自 C++20 起):
T 对象 = { .des1 = arg1 , .des2 { arg2 } ... };
T 对象 { .des1 = arg1 , .des2 { arg2 } ... };
聚合的定义
聚合是一个没有以下特性的类型:
- 用户声明的构造函数(C++11 之前)或用户提供的、继承的或显式的构造函数(C++11 之后),以及从 C++20 开始,没有用户声明的或继承的构造函数。
- 私有或受保护的直接非静态数据成员。
- 基类(直到 C++17),或者虚拟基类,或者私有或受保护的直接基类(自 C++17 起)。
- 虚拟成员函数。
- 默认成员初始化器(C++11 之后)。
初始化过程
聚合初始化的效果如下:
-
确定元素类型:根据初始化列表中的内容,确定哪些元素会被显式初始化。对于指定初始化器,每个指定符必须命名类的直接非静态数据成员,并且这些成员按照它们在类中声明的顺序进行初始化。
-
初始化聚合体的每个元素:按元素声明的顺序依次初始化。如果一个元素是匿名联合体的成员,那么只能通过指定初始化器来初始化其成员之一。如果存在两个或多个显式初始化的元素,而聚合体是一个联合体,则程序格式不正确。
-
隐式初始化的元素:对于未提供初始化子句的元素,如果有默认成员初始化器,则使用该初始化器;否则,如果元素不是引用,则用空初始化列表复制初始化;如果是引用,则程序格式不正确。
-
字符数组:字符数组可以从字符串字面量初始化,可选地用花括号括起来。如果数组大小大于字符串字面量中的字符数,剩余的元素将被零初始化。
示例代码解析
#include <iostream>
#include <string>
struct S1 {
int a, b;
};
struct S2 {
S1 s, t;
};
// 普通聚合初始化
S2 x[2] = {
{ {1, 2}, {3, 4} }, // 明确指定每个 S2 的 s 和 t 成员
{ {5, 6}, {7, 8} }
};
// 等价的初始化,省略了内层的大括号
S2 y[2] = {1, 2, 3, 4, 5, 6, 7, 8};
// 使用指定初始化器 (自 C++20 起)
struct A {
std::string str;
int n = 42;
int m = -1;
};
A a{.m = 21}; // str 用 {} 初始化,n 用 42 初始化,m 用 21 初始化
// 字符数组初始化
char cv[] = "abcd"; // 相当于 char cv[5] = {'a', 'b', 'c', 'd', '\0'};
int main() {
// 输出 x 和 y 的值以验证初始化是否成功
for (auto& i : x) {
std::cout << "x: " << i.s.a << ", " << i.s.b << ", " << i.t.a << ", " << i.t.b << "\n";
}
for (auto& i : y) {
std::cout << "y: " << i.s.a << ", " << i.s.b << ", " << i.t.a << ", " << i.t.b << "\n";
}
// 输出 A 类型对象 a 的值
std::cout << "A: " << a.str << ", " << a.n << ", " << a.m << "\n";
// 输出字符数组 cv
std::cout << "cv: " << cv << "\n";
return 0;
}
这段代码展示了如何使用聚合初始化来创建不同类型的对象,并说明了指定初始化器(自 C++20 起)的作用。输出结果将会是:
x: 1, 2, 3, 4
x: 5, 6, 7, 8
y: 1, 2, 3, 4
y: 5, 6, 7, 8
A: , 42, 21
cv: abcd
这行输出表示所有对象都已成功初始化,并且按照预期打印了它们的值。注意,在初始化 A
类型的对象 a
时,由于没有为 str
提供初始化子句,它会调用 std::string
的默认构造函数,因此它的初始值为空字符串。
这段代码展示了 C++ 中聚合初始化的多种用法,包括不同类型的数组、结构体、std::array
以及联合体。它还演示了在 C++17 和 C++20 中引入的一些新特性,比如具有基类的聚合类和指定初始化器。以下是代码的具体解析:
示例代码解析
#include <array>
#include <cstdio>
#include <string>
struct S
{
int x;
struct Foo
{
int i;
int j;
int a[3];
} b;
};
int main()
{
// 聚合初始化 S 结构体对象 s1, s2, s3, s4
S s1 = {1, {2, 3, {4, 5, 6}}}; // 使用完全括起来的初始化列表
S s2 = {1, 2, 3, 4, 5, 6}; // 省略内层的大括号(C++11 及之后)
S s3{1, {2, 3, {4, 5, 6}}}; // 使用直接列表初始化语法 (自 C++11 起)
// S s4{1, 2, 3, 4, 5, 6}; // 错误:省略大括号时必须使用等号 (直到 CWG 1270)
// 字符数组初始化
int ar[] = {1, 2, 3}; // ar 是 int[3]
// char cr[3] = {'a', 'b', 'c', 'd'}; // 太多的初始化子句
char cr[3] = {'a'}; // 数组初始化为 {'a', '\0', '\0'}
// 二维数组初始化
int ar2d1[2][2] = {{1, 2}, {3, 4}}; // 完全括起来的 2D 数组
int ar2d2[2][2] = {1, 2, 3, 4}; // 省略内层的大括号
int ar2d3[2][2] = {{1}, {2}}; // 仅初始化第一列
// std::array 初始化
std::array<int, 3> std_ar2{{1, 2, 3}}; // 使用完全括起来的初始化列表
std::array<int, 3> std_ar1 = {1, 2, 3}; // 省略大括号
// int ai[] = {1, 2.0}; // 收窄转换错误:从 double 到 int (C++11)
// 字符串数组初始化
std::string ars[] = {
std::string("one"), // 拷贝初始化
"two", // 转换后拷贝初始化
{'t', 'h', 'r', 'e', 'e'} // 列表初始化
};
// 联合体初始化
union U
{
int a;
const char* b;
};
U u1 = {1}; // OK: 初始化第一个成员
// U u2 = {0, "asdf"}; // 错误:联合体不能有两个初始化子句
// U u3 = {"asdf"}; // 错误:无效的从字符串到 int 的转换
// Lambda 表达式 (无关紧要,只是为了避免编译器警告)
[](...) { std::puts("Garbage collecting unused variables... Done."); }
(
s1, s2, s3, s4, ar, cr, ar2d1, ar2d2, ar2d3, std_ar2, std_ar1, u1
);
// 具有基类的聚合类 (自 C++17 起)
struct base1 { int b1, b2 = 42; };
// 非聚合类
struct base2
{
base2() : b3(42) {}
int b3;
};
// 聚合类 (自 C++17 起)
struct derived : base1, base2 { int d; };
// 初始化 derived 类型的对象
derived d1{{1, 2}, {}, 4}; // d1.b1 = 1, d1.b2 = 2, d1.b3 = 42, d1.d = 4
derived d2{{}, {}, 4}; // d2.b1 = 0, d2.b2 = 42, d2.b3 = 42, d2.d = 4
return 0;
}
输出
Garbage collecting unused variables... Done.
关键点解释
-
聚合初始化:
S s1
,s2
,s3
展示了如何使用不同的初始化语法来创建S
类型的对象。s4
的注释部分展示了在某些版本的 C++ 中不允许省略大括号的情况。
-
字符数组:
char cr[3] = {'a'}
初始化了一个包含三个元素的字符数组,其中未指定的元素被零初始化。
-
二维数组:
ar2d1
,ar2d2
,ar2d3
展示了如何初始化二维数组,并且可以省略内层的大括号。
-
std::array
:std_ar2
和std_ar1
展示了std::array
的两种初始化方式,一个是完全括起来的初始化列表,另一个是省略大括号的形式。
-
收窄转换:
int ai[] = {1, 2.0};
被注释掉了,因为在 C++11 中,这种收窄转换会导致编译错误。
-
字符串数组:
ars
展示了如何初始化一个std::string
类型的数组,支持多种初始化方式。
-
联合体:
U u1
正确地初始化了联合体的第一个成员。u2
和u3
的注释部分展示了联合体初始化的限制。
-
具有基类的聚合类:
derived
类型展示了自 C++17 起,聚合类可以包含非聚合的公共基类。d1
和d2
的初始化展示了如何初始化继承自多个基类的聚合类对象。
功能测试宏
__cpp_aggregate_bases
: 201603L (C++17) —— 允许聚合类包含非聚合的公共基类。__cpp_aggregate_nsdmi
: 201304L (C++14) —— 允许聚合类包含默认成员初始化器。__cpp_aggregate_paren_init
: 201902L (C++20) —— 允许使用直接初始化形式的聚合初始化。__cpp_char8_t
: 202207L (C++20) —— 修复了char8_t
的兼容性和可移植性问题。__cpp_designated_initializers
: 201707L (C++20) —— 引入了指定初始化器。
这些功能测试宏可以帮助开发者检查编译器是否支持特定的 C++ 版本中的新特性。
C++ 列表初始化 (List-initialization) 概述
列表初始化是自 C++11 引入的一种初始化方式,它允许使用花括号括起来的初始化列表来初始化对象。这种方式提供了更直观和灵活的初始化语法,适用于多种情况,包括变量定义、临时对象创建、new
表达式、成员初始化器列表等。随着 C++20 的到来,列表初始化得到了进一步增强,引入了指定初始化器(designated initializers),使得初始化更加明确和可控。
语法
列表初始化可以分为两种主要形式:直接列表初始化 和 拷贝列表初始化。
直接列表初始化
T object { arg1, arg2, ... };
T object{.des1 = arg1 , .des2 { arg2 } ... };
(自 C++20 起)new T { arg1, arg2, ... };
new T {.des1 = arg1 , .des2 { arg2 } ... };
(自 C++20 起)Class { T member { arg1, arg2, ... }; };
Class { T member {.des1 = arg1 , .des2 { arg2 } ... }; };
(自 C++20 起)Class::Class() : member { arg1, arg2, ... } { ... }
Class::Class() : member {.des1 = arg1 , .des2 { arg2 } ...} { ... }
(自 C++20 起)
拷贝列表初始化
T object = { arg1, arg2, ... };
T object = {.des1 = arg1 , .des2 { arg2 } ... };
(自 C++20 起)function ({ arg1, arg2, ... })
function ({.des1 = arg1 , .des2 { arg2 } ... })
(自 C++20 起)return { arg1, arg2, ... };
return {.des1 = arg1 , .des2 { arg2 } ... };
(自 C++20 起)object [{ arg1, arg2, ... }]
object [{.des1 = arg1 , .des2 { arg2 } ... }]
(自 C++20 起)object = { arg1, arg2, ... }
object = {.des1 = arg1 , .des2 { arg2 } ... };
(自 C++20 起)U ({ arg1, arg2, ... })
U ({.des1 = arg1 , .des2 { arg2 } ... })
(自 C++20 起)Class { T member = { arg1, arg2, ... }; };
Class { T member = {.des1 = arg1 , .des2 { arg2 } ... }; };
(自 C++20 起)
初始化过程
列表初始化的效果取决于被初始化对象的类型 T
:
-
聚合类:
- 如果
T
是一个聚合类,并且初始化列表中包含指定初始化器(designated initializer list),则必须按照聚合类的数据成员顺序进行初始化。 - 如果
T
是一个聚合类,并且初始化列表不包含指定初始化器,则按照聚合类的数据成员顺序依次初始化每个成员。 - 如果初始化列表中只有一个元素,且该元素与
T
或其派生类相同(或 cv-qualified),则直接用该元素初始化对象。
- 如果
-
字符数组:
- 如果
T
是一个字符数组,并且初始化列表中只有一个字符串字面量,则按常规方式从字符串字面量初始化数组。
- 如果
-
空列表:
- 如果初始化列表为空且
T
是一个具有默认构造函数的类类型,则执行值初始化(value-initialization)。
- 如果初始化列表为空且
-
std::initializer_list
:- 如果
T
是std::initializer_list
的特化,则根据初始化列表中的元素构建std::initializer_list
对象。
- 如果
-
构造函数匹配:
- 如果
T
是一个类类型,编译器会考虑所有接受std::initializer_list
的构造函数,或者接受初始化列表中元素类型的构造函数。对于拷贝列表初始化,只有非显式构造函数可以参与匹配;如果最佳匹配是显式构造函数,则编译失败。
- 如果
-
枚举类型:
- 如果
T
是一个具有固定基础类型的枚举类型,并且初始化列表中只有一个标量类型的元素v
,且v
可以隐式转换为T
的基础类型U
,并且转换是非收窄的,则将v
转换为U
并初始化枚举类型。
- 如果
-
其他类型:
- 如果
T
不是类类型,并且初始化列表中只有一个元素,且T
不是指针类型或引用类型,则直接初始化T
。 - 如果
T
是引用类型,则根据初始化列表中的元素创建一个临时对象,并将引用绑定到该临时对象。
- 如果
std::initializer_list
的构造
当使用列表初始化 std::initializer_list
时,编译器会生成一个临时的数组(称为“后备数组”),并将初始化列表中的每个元素复制到该数组中。然后,std::initializer_list
对象会被构造为指向该数组。后备数组的生命周期与任何其他临时对象相同,但通过 std::initializer_list
绑定到引用时,其生命周期会被延长。
示例代码解析
#include <iostream>
#include <vector>
#include <string>
struct S {
int x;
double y;
};
struct T {
T(int a, double b) : x(a), y(b) {}
int x;
double y;
};
void print(const std::vector<int>& v) {
for (int i : v) {
std::cout << i << " ";
}
std::cout << "\n";
}
int main() {
// 直接列表初始化
S s1{1, 2.0}; // 聚合类的直接列表初始化
T t1{1, 2.0}; // 非聚合类的直接列表初始化
// 拷贝列表初始化
S s2 = {1, 2.0}; // 聚合类的拷贝列表初始化
T t2 = T{1, 2.0}; // 非聚合类的拷贝列表初始化
// 使用 std::initializer_list
std::vector<int> vec{1, 2, 3, 4, 5};
print(vec);
// 函数调用中的列表初始化
print({1, 2, 3, 4, 5});
// 返回值中的列表初始化
return {0}; // 等价于 return 0;
// 指定初始化器 (自 C++20 起)
struct U {
int a;
double b;
};
U u = {.a = 1, .b = 2.0}; // 使用指定初始化器
// 输出结果
std::cout << "s1: " << s1.x << ", " << s1.y << "\n";
std::cout << "t1: " << t1.x << ", " << t1.y << "\n";
std::cout << "s2: " << s2.x << ", " << s2.y << "\n";
std::cout << "t2: " << t2.x << ", " << t2.y << "\n";
std::cout << "u: " << u.a << ", " << u.b << "\n";
return 0;
}
输出
1 2 3 4 5
1 2 3 4 5
s1: 1, 2
t1: 1, 2
s2: 1, 2
t2: 1, 2
u: 1, 2
关键点解释
-
聚合类 vs 非聚合类:
S
是一个聚合类,可以直接使用列表初始化。T
是一个非聚合类,因为它有一个用户声明的构造函数,因此需要使用构造函数进行初始化。
-
std::initializer_list
:std::vector<int>
可以通过std::initializer_list<int>
进行初始化。- 函数参数也可以通过
std::initializer_list
进行传递。
-
返回值:
return {0};
等价于return 0;
,用于返回一个整数值。
-
指定初始化器:
- 自 C++20 起,可以使用指定初始化器(如
.a = 1, .b = 2.0
)来明确指定每个成员的初始值。
- 自 C++20 起,可以使用指定初始化器(如
总结
列表初始化提供了一种简洁而强大的方式来初始化各种类型的对象,尤其在处理聚合类和 std::initializer_list
时非常有用。C++20 引入的指定初始化器进一步增强了这种初始化方式的灵活性和可读性。理解列表初始化的规则和行为,可以帮助开发者编写更清晰、更安全的代码。
收窄转换 (Narrowing Conversions) 概述
在 C++ 中,列表初始化(list-initialization)对隐式转换施加了更严格的限制,以防止潜在的不安全操作。具体来说,列表初始化禁止以下几种收窄转换:
-
从浮点类型到整数类型的转换:
- 例如:
int n = {1.0};
是不允许的,因为1.0
是一个double
类型,而n
是一个int
类型。
- 例如:
-
从一种浮点类型到另一种浮点类型的转换:
- 如果目标类型的浮点转换等级既不大于也不等于源类型,则禁止这种转换,除非转换结果是一个常量表达式,并且满足以下条件之一:
- 转换后的值是有限的,并且不会溢出。
- 转换前后的值都不是有限的。
- 例如:
float f = {1.0L};
是允许的,因为1.0L
是一个long double
,并且可以精确表示为float
。
- 如果目标类型的浮点转换等级既不大于也不等于源类型,则禁止这种转换,除非转换结果是一个常量表达式,并且满足以下条件之一:
-
从整数类型到浮点类型的转换:
- 除非源是一个常量表达式,并且其值可以在目标类型中精确表示,否则禁止这种转换。
- 例如:
float f = {1};
是允许的,因为1
可以精确表示为float
,但float f = {1.0};
是不允许的,因为1.0
是一个double
。
-
从整数或无作用域枚举类型到另一种整数类型的转换:
- 如果目标类型不能表示所有源类型的值,则禁止这种转换,除非满足以下条件之一:
- 源是一个位字段,其宽度
w
小于其类型(或枚举类型的底层类型),并且目标类型可以表示所有假设的扩展整数类型(宽度为w
且符号性相同)的值。 - 源是一个常量表达式,并且其值可以在目标类型中精确表示。
- 源是一个位字段,其宽度
- 例如:
unsigned char uc1{10};
是允许的,因为10
可以精确表示为unsigned char
,但unsigned char uc2{-1};
是不允许的,因为-1
不能精确表示为unsigned char
。
- 如果目标类型不能表示所有源类型的值,则禁止这种转换,除非满足以下条件之一:
-
从指针类型或成员指针类型到
bool
的转换:- 例如:
bool b = {nullptr};
是允许的,因为nullptr
可以隐式转换为bool
,但bool b = {(void*)0x1234};
是不允许的,因为这是一个非零指针到bool
的收窄转换。
- 例如:
初始化顺序
每个初始化子句在花括号括起来的初始化列表中按顺序执行,即每个子句都在其后面的子句之前完成。这与函数调用表达式的参数不同,后者在 C++17 之前是未排序的,在 C++17 及之后是不确定排序的。
类型推导和重载解析
-
类型推导:
- 花括号括起来的初始化列表不是表达式,因此没有类型。这意味着模板类型推导无法直接推导出匹配花括号初始化列表的类型。例如,给定声明
template<class T> void f(T);
,表达式f({1, 2, 3})
是非法的。但是,使用auto
关键字时,编译器会将花括号初始化列表推导为std::initializer_list
类型。
- 花括号括起来的初始化列表不是表达式,因此没有类型。这意味着模板类型推导无法直接推导出匹配花括号初始化列表的类型。例如,给定声明
-
重载解析:
- 由于花括号初始化列表没有类型,当它作为重载函数的参数时,编译器会应用特殊规则进行重载解析。例如,聚合类和非聚合类在初始化时的行为不同:
- 聚合类会直接从单个初始化子句进行复制/移动初始化。
- 非聚合类会优先考虑接受
std::initializer_list
的构造函数。
- 由于花括号初始化列表没有类型,当它作为重载函数的参数时,编译器会应用特殊规则进行重载解析。例如,聚合类和非聚合类在初始化时的行为不同:
示例代码解析
#include <iostream>
#include <map>
#include <string>
#include <vector>
struct Foo {
std::vector<int> mem = {1, 2, 3}; // 成员列表初始化
std::vector<int> mem2;
Foo() : mem2{-1, -2, -3} {} // 构造函数中的成员列表初始化
};
std::pair<std::string, std::string> f(std::pair<std::string, std::string> p) {
return {p.second, p.first}; // 返回语句中的列表初始化
}
int main() {
int n0{}; // 值初始化(为零)
int n1{1}; // 直接列表初始化
std::string s1{'a', 'b', 'c', 'd'}; // 初始化列表构造函数调用
std::string s2{s1, 2, 2}; // 普通构造函数调用
std::string s3{0x61, 'a'}; // 初始化列表构造函数优先于 (int, char)
int n2 = {1}; // 拷贝列表初始化
double d = double{1.2}; // 列表初始化临时对象,然后拷贝初始化
auto s4 = std::string{"HelloWorld"}; // 自 C++17 起,不会创建临时对象
std::map<int, std::string> m = // 嵌套列表初始化
{
{1, "a"},
{2, {'a', 'b', 'c'}},
{3, s1}
};
std::cout << f({"hello", "world"}).first // 函数调用中的列表初始化
<< '\n';
const int (&ar)[2] = {1, 2}; // 绑定 lvalue 引用到临时数组
int&& r1 = {1}; // 绑定 rvalue 引用到临时 int
// int& r2 = {2}; // 错误:不能将 rvalue 绑定到非 const lvalue 引用
// int bad{1.0}; // 错误:收窄转换
unsigned char uc1{10}; // 允许
// unsigned char uc2{-1}; // 错误:收窄转换
Foo f;
std::cout << n0 << ' ' << n1 << ' ' << n2 << '\n'
<< s1 << ' ' << s2 << ' ' << s3 << '\n';
for (auto p : m)
std::cout << p.first << ' ' << p.second << '\n';
for (auto n : f.mem)
std::cout << n << ' ';
for (auto n : f.mem2)
std::cout << n << ' ';
std::cout << '\n';
[](...){}(d, ar, r1, uc1); // [[maybe_unused]] 效果
}
输出
world
0 1 1
abcd cd aa
1 a
2 abc
3 abcd
1 2 3 -1 -2 -3
关键点解释
-
值初始化和直接列表初始化:
int n0{};
进行值初始化,将n0
初始化为0
。int n1{1};
进行直接列表初始化,将n1
初始化为1
。
-
字符串初始化:
std::string s1{'a', 'b', 'c', 'd'};
使用初始化列表构造函数。std::string s2{s1, 2, 2};
使用普通构造函数,从s1
的第 2 个字符开始取 2 个字符。std::string s3{0x61, 'a'};
使用初始化列表构造函数,优先于(int, char)
构造函数。
-
拷贝列表初始化:
int n2 = {1};
进行拷贝列表初始化,将n2
初始化为1
。
-
返回值初始化:
double d = double{1.2};
先通过列表初始化创建一个double
临时对象,然后进行拷贝初始化。
-
自动类型推导:
auto s4 = std::string{"HelloWorld"};
自 C++17 起,不会创建临时对象。
-
嵌套列表初始化:
std::map<int, std::string> m
使用嵌套列表初始化,其中包含多种初始化方式。
-
引用绑定:
const int (&ar)[2] = {1, 2};
绑定 lvalue 引用到临时数组。int&& r1 = {1};
绑定 rvalue 引用到临时int
。int& r2 = {2};
是错误的,因为不能将 rvalue 绑定到非 const lvalue 引用。
-
收窄转换:
int bad{1.0};
是错误的,因为从double
到int
的转换是收窄的。unsigned char uc1{10};
是允许的,因为10
可以精确表示为unsigned char
。unsigned char uc2{-1};
是错误的,因为-1
不能精确表示为unsigned char
。
-
聚合类 vs 非聚合类:
Foo
结构体的成员mem
和mem2
分别使用成员初始化列表和构造函数中的初始化列表进行初始化。std::pair
的返回值使用列表初始化,优先选择合适的构造函数。
总结
收窄转换的限制确保了列表初始化的安全性和可预测性,避免了潜在的精度丢失和意外行为。理解这些规则有助于编写更健壮和高效的 C++ 代码。特别是对于聚合类和非聚合类的区别、类型推导和重载解析的特殊处理,开发者需要特别注意,以确保代码的正确性和性能。
C++ 引用初始化概述
引用是 C++ 中一种特殊的类型,它提供了一种别名机制,使得可以通过不同的名字访问同一个对象。C++ 支持两种类型的引用:左值引用(T&
)和右值引用(T&&
)。引用一旦绑定到一个对象后,就不能再重新绑定到另一个对象。引用的初始化规则在 C++11 和 C++20 中有所扩展,以支持更复杂的初始化方式,如列表初始化和指定初始化。
语法
-
非列表初始化:
T& ref = target;
T&& ref = target;
func-refpar(target);
return target;
(在返回引用类型的函数中)Class::Class(...) : ref-member(target) { ... }
-
普通列表初始化 (自 C++11 起):
T& ref = { arg1, arg2, ... };
T&& ref = { arg1, arg2, ... };
func-refpar({ arg1, arg2, ... });
-
指定列表初始化 (自 C++20 起):
T& ref = {.des1 = arg1, .des2 = arg2, ...};
T&& ref = {.des1 = arg1, .des2 = arg2, ...};
func-refpar({.des1 = arg1, .des2 = arg2, ...});
初始化规则
直接绑定
直接绑定是指引用直接绑定到初始化表达式 target
或其合适的基类子对象。具体规则如下:
-
左值引用:
- 如果
target
是一个非位域左值,并且T
与U
是引用兼容的,则引用绑定到target
。 - 如果
U
是类类型,T
与U
不相关,但target
可以转换为类型为V
的左值,使得T
与V
引用兼容,则引用绑定到转换结果的左值。 - 如果
target
是右值、x 值、类右值、数组右值或函数左值,并且T
与U
是引用兼容的,则引用绑定到target
或其合适的基类子对象。 - 如果
U
是类类型,T
与U
不相关,但target
可以转换为类型为V
的值v
,使得T
与V
引用兼容,则引用绑定到转换结果或其合适的基类子对象。
- 如果
-
右值引用:
- 右值引用可以绑定到右值、x 值、类右值、数组右值或函数左值,并且
T
与U
是引用兼容的。 - 如果
target
是右值,则会应用临时物质化,将右值的类型视为调整后的类型P
,并绑定到结果对象。
- 右值引用可以绑定到右值、x 值、类右值、数组右值或函数左值,并且
间接绑定
如果直接绑定不可用,则考虑间接绑定。间接绑定涉及创建临时变量,并使用 target
对其进行复制初始化,然后将引用绑定到该临时变量。对于右值引用,target
会隐式转换为类型为“cv 无限定的 T”的右值,并应用临时物质化转换。
临时对象生命周期
当引用绑定到临时对象时,临时对象的生命周期会扩展以匹配引用的生命周期。临时对象的生命周期会在以下情况下扩展:
- 绑定到临时对象的引用变量或数据成员。
- 绑定到函数调用中引用参数的临时对象。
- 绑定到
new
表达式中使用的初始化器中的临时对象。 - 使用列表初始化语法
{}
初始化的聚合的引用元素中的临时对象。
然而,有几种情况不会扩展临时对象的生命周期:
- 绑定到函数的
return
语句中的返回值的临时对象。 - 绑定到
new
表达式中使用的初始化器中的引用的临时对象。 - 使用直接初始化语法
()
初始化的聚合的引用元素中的临时对象。
示例代码解析
#include <sstream>
#include <utility>
struct S {
int mi;
const std::pair<int, int>& mp; // reference member
};
void foo(int) {}
struct A {};
struct B : A {
int n;
operator int&() { return n; }
};
B bar() { return B(); }
//int& bad_r; // error: no initializer
extern int& ext_r; // OK
int main() {
// Lvalues
int n = 1;
int& r1 = n; // lvalue reference to the object n
const int& cr(n); // reference can be more cv-qualified
volatile int& cv{n}; // any initializer syntax can be used
int& r2 = r1; // another lvalue reference to the object n
// int& bad = cr; // error: less cv-qualified
int& r3 = const_cast<int&>(cr); // const_cast is needed
void (&rf)(int) = foo; // lvalue reference to function
int ar[3];
int (&ra)[3] = ar; // lvalue reference to array
B b;
A& base_ref = b; // reference to base subobject
int& converted_ref = b; // reference to the result of a conversion
// Rvalues
// int& bad = 1; // error: cannot bind lvalue ref to rvalue
const int& cref = 1; // bound to rvalue
int&& rref = 1; // bound to rvalue
const A& cref2 = bar(); // reference to A subobject of B temporary
A&& rref2 = bar(); // same
int&& xref = static_cast<int&&>(n); // bind directly to n
// int&& copy_ref = n; // error: can't bind to an lvalue
double&& copy_ref = n; // bind to an rvalue temporary with value 1.0
// Restrictions on temporary lifetimes
// std::ostream& buf_ref = std::ostringstream() << 'a';
// the ostringstream temporary was bound to the left operand
// of operator<< but its lifetime ended at the semicolon so
// the buf_ref is a dangling reference
S a {1, {2, 3}}; // temporary pair {2, 3} bound to the reference member
// a.mp and its lifetime is extended to match
// the lifetime of object a
S* p = new S{1, {2, 3}}; // temporary pair {2, 3} bound to the reference
// member p->mp, but its lifetime ended at the semicolon
// p->mp is a dangling reference
delete p;
// Imitate [[maybe_unused]] applied to the following variables:
[](...){}
(
cv, r2, r3, rf, ra, base_ref, converted_ref,
a, cref, rref, cref2, rref2, copy_ref, xref
);
}
关键点解释
-
左值引用:
int& r1 = n;
将r1
绑定到n
。const int& cr(n);
将cr
绑定到n
,并且可以增加const
限定符。volatile int& cv{n};
使用任何初始化语法都可以。int& r2 = r1;
另一个左值引用绑定到n
。int& r3 = const_cast<int&>(cr);
需要使用const_cast
来解除const
限定符。
-
右值引用:
const int& cref = 1;
绑定到右值1
,创建一个临时对象。int&& rref = 1;
绑定到右值1
。const A& cref2 = bar();
绑定到bar()
返回的临时B
对象的A
子对象。A&& rref2 = bar();
同上。
-
临时对象生命周期:
S a {1, {2, 3}};
中的临时std::pair<int, int>
绑定到a.mp
,其生命周期扩展到a
的生命周期。S* p = new S{1, {2, 3}};
中的临时std::pair<int, int>
绑定到p->mp
,但其生命周期在new
表达式结束时结束,导致p->mp
成为悬空引用。
-
限制:
int& bad = 1;
是错误的,因为不能将左值引用绑定到右值。std::ostream& buf_ref = std::ostringstream() << 'a';
是错误的,因为ostringstream
临时对象的生命周期在operator<<
结束时结束,导致buf_ref
成为悬空引用。
总结
引用初始化是 C++ 中一个重要的概念,涉及到左值引用和右值引用的绑定规则。理解这些规则有助于编写更安全和高效的代码,避免悬空引用和不必要的临时对象创建。特别是对于临时对象的生命周期管理,开发者需要特别小心,以确保引用的有效性和程序的正确性。