何为初始化
变量的类型决定变量所占用的内存大小和布局;变量在创建是获得一个特定的值,我说这个变量被初始化了。
分类及标准
变量的初始化是C++标准的一个重要组成部分,C++初始化按照是否存在拷贝,可将初始化划分为直接初始化和拷贝初始化;其他初始化均可以归类为此两大类。
C++标准中规定,如果变量使用赋值运算符(=)初始化就是拷贝初始化,否则就是直接初始化。
C++标准
按照C++标准引入时间,笔者将变量初始化相关标准汇总如下:
- C++98标准的直接初始化,拷贝初始化,聚合初始化,圆括号初始化
- C++11标准的列表初始化和initializer_list;
- C++17标准的强制省略拷贝优化,列表初始化类型推导,聚合初始化扩展(允许基类)
- C++20标准的聚合初始化的扩展,指定初始化器初始化。
C++98
C++98标准是C++语言的第一个major版本;此标准引入的初始化包括直接初始化,拷贝初始化,圆括号初始化,花括号以及聚合初始化。
按照前述初始化标准,圆括号初始化属于直接初始化,聚合初始化是一种特殊的拷贝初始化。
直接初始化
未通过拷贝构造实现的初始化就是直接初始化 ,此初始化方式一般通过直接调用构造函数初始化变量。例如:
string s(5,'c'); // s被初始化为"ccccc"
拷贝初始化
拷贝初始化是相对于直接初始化而言的,拷贝初始化通过调用类型的拷贝构造实现对象初始化。例如:
string name = "liuguang";
在C++98标准中,name赋值基于隐式转换经历了2个步骤:
- 调用string入参为const char*构造函数,产生临时string变量temp;
- 执行赋值操作调用入参为temp的string拷贝构造函数,完成name的初始化。
但可惜的是因为编译器优化,我们调试看到却是直接调用构造函数初始化,name初始化会直接调用string入参为const char*构造函数。
所以name初始化本应属于拷贝初始化的,但是优化后会被别人误解为直接初始化。GCC的-fno-elide-constructors和MSVC的/Od可以关闭此编译器优化。
圆括号初始化
圆括号初始化使用()来初始化变量,其是基于使用形式的命名方式,例如:
double d(1.3); // d被初始化为1.3
string s(5,'c'); // s被初始化为"ccccc"
聚合初始化
聚合初始化一般适用于下述两种初始化场景:
- 数组初始化
char a[3] = {
'a', 'b', '\0'};
- 满足下列条件的class,struct或union
– 所有成员都是public的非静态数据成员
– 用户没有定义任何构造函数
– 没有虚成员函数
– 没有基类
– 没有类内初始化
struct AggrPerson
{
std::string name;
int age;
};
AggrPerson aggrPerson = {
"liuguang", 20};
花括号初始化
C++98标准中,花括号初始化除了聚合初始化外,仅有一种使用场景。例如
int units{
2};
C++11
C++11标准是C++语言的第二个major版本,在C++历史上具有具足重要的地位。C++98标准存在多种形式的初始化方式,C++11引入了初始化列表使得这一问题得到了根本性改善。
列表初始化
由于C++98标准存在多种形式的初始化方式,从而导致初始化问题变的极为复杂,为了解决C++98存在的棘手问题,C++11中花括号初始化变量得到了全面应用。这种初始化新形式被成为列表初始化。列表初始化,允许我们采用{}方式,统一初始化所有类型。
int a[] = {
1,2,3 };
int aa[]{
1, 2,3 };
std::vector<int> aas{
1,2,3 };
std::unordered_map<std::string, int> az{
{
"1", 1}, {
"2", 2} };
类型收窄
类型收窄一般指可能导致数据变化或者精度丢失的隐式转换。应用于内置变量时,列表初始化有一个重要特点,列表初始化可以防止类型收窄:如果初始化列表初始化变量存在信息丢失风险时,编译器会报错。而且列表初始化是C++11中,唯一可以防止类型收窄的初始化方式,这也是列表初始化区别其他初始化最重要的特点。
// 浮点型到整型的转换
int aVal = 2.0; // C++98/0x 标准可以通过编译
int aVal2 = {
2.0}; // C++11 标准提示编译错误,类型收窄
// 整型到浮点型的转换
float c = 1e70; // C++98/0x 标准可以通过编译
float d = {
1e70}; // C++11 标准提示编译错误,类型收窄
可能导致类型收窄的典型场景:
- 浮点数转化为整数
- 高精度浮点数转换为低精度浮点数
- 整型(非强类型枚举)转换为浮点数
- 整型(非强类型枚举)转换为较低长度的整型
initializer_list
initializer_list由C++11 STL引入,他可以解决任意长度的初始化列表,但要求参数的类型同为T或可隐式转换为T,initializer_list的定义:
template <class T> class initializer_list
在进行函数调用的时候需要使用花括号将所有的参数括起来。例如:
void errorMsg(std::initializer_list<std::string> str) //可变参数,所有参数类型一致
{
for (auto beg = str.begin(); beg != str.end(); ++beg)
std::cout << *beg << " ";
std::cout << std::endl;
}
//调用
errorMsg({
"hello","error",error}); // error为string类型
errorMsg({
"hello2",well}); // well为string类型
需要说明的是initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。
C++17
进一步完善初始化,引入了强制省略拷贝优化,列表初始化类型推导两特性 。列表初始化类型推导是关于auto类型推导,强制省略拷贝优化是关于对象拷贝初始化的优化,聚合初始化扩展(允许基类)。
列表初始化类型推导
C++11的列表初始化规则,在与 auto联合使用时经常无法达到程序员的期望并出错,为此C++17对列表初始化规则进行增强。
C++17对列表初始化的增强,可总结为下面三条规则:
- auto var {element}; 将 var 推导为 与 element相同的类型
- auto var {element1, element2, …}; 此形式非法,提示编译错误
- auto var = {element1, element2, …}; var 推导为 std::initializer_list,其中 T为 element 类型
auto var = {element1, element2, …};
此种类型推导,要求所有element类型必须相同,或者可转换为相同类型。
auto v {
1}; // 正确:v 推导为 int
auto w {
1, 2}; // 错误: 初始化列表只能为单元素
/* C++17 增加的新规则 */
auto x = {
1}; // 正确:x 推导为 std::initializer_list<int>
auto y = {
1, 2}; // 正确:y 推导为 std::initializer_list<int>
auto z = {
1, 2, 3.0}; // 错误: 初始化列表中的元素类型必须相同
强制省略拷贝优化
C++标准一直在试图减少某些临时变量或者拷贝的操作,从而实现性能的提升,这是C++标准一直试图解决的问题,C++17之前的编译器有些已经支持了此忽略拷贝操作,但是C++标准却不做强制要求。庆幸的是C++17把省略拷贝写进了标准中,所以从C++17以后忽略拷贝将变成强制要求。
C++17规定,省略拷贝优化会出现在RVO (Return Value Optimization)和NRVO (Named Return Value Optimization),异常捕获以及基于临时构建对象四种场景。
NRVO (Named Return Value Optimization)
如果一个函数返回一个Named Return Value Optimization,这时候强制省略拷贝优化将会生效。
class Thing
{
public:
Thing();
virtual ~Thing();
Thing(const Thing&);
};
Thing f()
{
Thing t;
return t;
}
Thing t2 = f();
上述代码生成的汇编代码(https://godbolt.org/可以帮我们实现代码转换)
f():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call Thing::Thing() [complete object constructor]
nop
mov rax, QWORD PTR [rbp-8]
leave
ret
t2:
.zero 1
__static_initialization_and_destruction_0():
push rbp
mov rbp, rsp
mov eax, OFFSET FLAT:t2
mov rdi, rax
call f()
mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:t2
mov edi, OFFSET FLAT:_ZN5ThingD1Ev
call __cxa_atexit
nop
pop rbp
ret
_GLOBAL__sub_I_f():
push rbp
mov rbp, rsp
call __static_initialization_and_destruction_0()
pop rbp
ret
我们可看出C++17标准下f()只调用了1次对象构造,然后将生成的对象采用move方式移交给t2。忽略了C++17之前函数返回临时变量的生成和构造。
RVO (Return Value Optimization)
C++17中,如果一个函数返回RVO (Return Value Optimization),其同样会被强制省略拷贝优化。
class Thing
{
public:
Thing();
~Thing();
Thing(const Thing&);
};
Thing f()
{
return Thing();
}
Thing t2 = f();
上述代码生成的汇编代码(https://godbolt.org/可以帮我们实现代码转换)
f():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call Thing::Thing() [complete object constructor]
mov rax, QWORD PTR [rbp-8]
leave
ret
t2:
.zero 1
__static_initialization_and_destruction_0():
push rbp
mov rbp, rsp
mov eax, OFFSET FLAT:t2
mov rdi, rax
call f()
mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:t2
mov edi, OFFSET FLAT:_ZN5ThingD1Ev
call __cxa_atexit
nop
pop rbp
ret
_GLOBAL__sub_I_f():
push rbp
mov rbp, rsp
call __static_initialization_and_destruction_0()
pop rbp
ret
我们可看出C++17标准下f()只调用了1次对象构造,然后将生成的对象采用move方式移交给t2。忽略了C++17之前函数返回临时变量的生成和构造。实现形式和NRVO一样。
Constructed from a temporary
如果一个对象由临时变量构造时,这时候同样会被强制省略拷贝优化。参考举例:
class Thing
{
public:
Thing();
~Thing();
Thing(const Thing&);
};
Thing t2 = Thing();
Thing t3 = Thing(Thing());
其生成的汇编代码(https://godbolt.org/可以帮我们实现代码转换)
t2:
.zero 1
t3:
.zero 1
__static_initialization_and_destruction_0():
push rbp
mov rbp, rsp
mov edi, OFFSET FLAT:t2
call Thing::Thing() [complete object constructor]
mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:t2
mov edi, OFFSET FLAT:_ZN5ThingD1Ev
call __cxa_atexit
mov edi, OFFSET FLAT:t3
call Thing::Thing() [complete object constructor]
mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:t3
mov edi, OFFSET FLAT:_ZN5ThingD1Ev
call __cxa_atexit
nop
pop rbp
ret
_GLOBAL__sub_I_t2:
push rbp
mov rbp, rsp
call __static_initialization_and_destruction_0()
pop rbp
ret
从上述汇编代码,我们可以得出这样的结论:t2和t3都都是,首先完成一次对象构造,然后通过move的方式将对象移交给他们。中间忽略了多次的对象拷贝构造。
Exception object caught by value
异常抛出以值的方式捕获时,强制省略拷贝优化将会发生。参考举例:
struct Thing
{
Thing();
Thing(const Thing&);
};
void foo()
{
Thing c;
throw c;
}
int main()
{
try
{
foo();
}
catch(Thing c)
{
}
}
其生成的汇编代码(https://godbolt.org/可以帮我们实现代码转换)
foo():
push rbp
mov rbp, rsp
push r12
push rbx
sub rsp, 16
lea rax, [rbp-17]
mov rdi, rax
call Thing::Thing() [complete object constructor]
mov edi, 1
call __cxa_allocate_exception
mov rbx, rax
lea rax, [rbp-17]
mov rsi, rax
mov rdi, rbx
call Thing::Thing(Thing const&) [complete object constructor]
mov edx, 0
mov esi, OFFSET FLAT:typeinfo for Thing
mov rdi, rbx
call __cxa_throw
mov r12, rax
mov rdi, rbx
call __cxa_free_exception
mov rax, r12
mov rdi, rax
call _Unwind_Resume
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 24
call foo()
.L8:
mov eax, 0
jmp .L10
mov rbx, rax
mov rax, rdx
cmp rax, 1
je .L7
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L7:
mov rax, rbx
mov rdi, rax
call __cxa_get_exception_ptr
mov rdx, rax
lea rax, [rbp-17]
mov rsi, rdx
mov rdi, rax
call Thing::Thing(Thing const&) [complete object constructor]
mov rax, rbx
mov rdi, rax
call __cxa_begin_catch
call __cxa_end_catch
jmp .L8
.L10:
mov rbx, QWORD PTR [rbp-8]
leave
ret
typeinfo for Thing:
.quad vtable for __cxxabiv1::__class_type_info+16
.quad typeinfo name for Thing
typeinfo name for Thing:
.string "5Thing"
分析汇编代码,我们可发现catch(Thing c)中c对象由void foo()的c对象通过直接拷贝构造而来,只进行了一次拷贝构造,所以此处强制省略拷贝优化是生效的。
允许继承的聚合初始化
从C++17开始,聚合还可以有基类,可以初始化从其他类/结构体派生而来的结构体:
struct NewPerson: AggrPerson
{
bool isMan;
};
NewPerson person{
{
"liuguang", 20}, true};
聚合初始化现在支持以嵌套括号传递初始值给基类的成员变量,嵌套括号也是可以省略的:
NewPerson person{
"liuguang", 20, true};
从C++17开始,聚合定义如下:
- 可以是一个数组
- 或是一自定义类型(类、结构体、或联合),但需要:
– 没有用户定义或显式的构造函数
– 没有通过using声明继承而来的构造函数
– 没有private或protected的非静态成员变量
– 没有虚函数
– 没有virtual,private或protected的基类
要能使用聚合,它还必须在初始化过程中没有private或protected的基类成员或构造函数。
struct A // C++17聚合体
{
A() = delete;
};
struct B // C++17聚合体
{
B() = default;
int i = 0;
};
struct C // C++17聚合体
{
C(C&&) = default;
int a, b;
};
A a{
}; // C++17合法
B b = {
1}; // C++17合法
auto* c = new C{
2, 3}; // C++17合法
C++17引入了新的类型粹取is_aggregate<>用于测试某个类型是否是聚合。
template<typename T>
struct D : std::string, std::complex<T>
{
std::string data;
};
D<float> s{
{
"hello"}, {
4.5,6.7}, "world"}; // C++17开始正确
std::cout << std::is_aggregate<decltype(s)>::value; // 输出: 1
C++20
C++20对聚合初始化,又有了深入进一步的优化提升,具体优化包括如下两个方面:第一,禁止聚合体有用户声明的构造函数,第二,兼容C语言的指定初始化。
禁止聚合体构造函数
C++20 [P1008R1]提议要求只要声明了构造函数,那么此class&struct将不是一个聚合体。例如:
struct A // C++17及以前是聚合体,C++20 非聚合体
{
A() = delete;
};
struct B // C++17及以前是聚合体,C++20 非聚合体
{
B() = default;
int i = 0;
};
struct C // C++17及以前是聚合体,C++20 非聚合体
{
C(C&&) = default;
int a, b;
};
A a{
}; // C++20不合法,C++17及以前合法
B b = {
1}; // C++20不合法,C++17及以前合法
auto* c = new C{
2, 3}; // C++20不合法,C++17及以前合法
指定初始化
我们可以使用如下两种C++20支持的指定初始化方式:
T 对象 = {
.指定符1 = 实参1 , .指定符2 {
实参2 } ... };
T 对象 {
.指定符1 = 实参1 , .指定符2 {
实参2 } ... };
- 每个指定符必须指名T的一个直接非静态数据成员,而表达式中所用的所有指定符必须按照与 T 的数据成员相同的顺序出现。例如:
struct A
{
int x;
int y;
int z;
};
A a{
.y = 2, .x = 1}; // Error:指定符的顺序不匹配声明顺序
A b{
.x = 1, .z = 2}; // OK:b.y 被初始化为 0
- 指定初始化器所指名的每个直接非静态数据成员,从它的指定符后随的对应花括号或等号指定器初始化。禁止窄化转换。
- 指定初始化器可以用来将联合体初始化为它的首个成员之外的状态。只可以为一个联合体提供一个初始化器。
union u
{
int a;
const char* b;
};
u f = {
.b = "asdf"}; // OK:联合体的活跃成员是 b
u g = {
.a = 1, .b = "asdf"}; // Error:只可提供一个初始化器
- 对于非联合体的聚合体中未提供指定初始化器的元素,按上述针对初始化器子句的数量少于成员数量时的规则进行初始化(如果提供默认成员初始化器就使用它,否则用空列表进行初始化)
struct A
{
string str;
int n = 42;
int m = -1;
};
A{
.m = 21} // 以 {} 初始化 str,这样会调用默认构造函数
// 然后以 = 42 初始化 n
// 然后以 = 21 初始化 m
- 如果指定初始化器子句初始化的聚合体拥有一个匿名union成员,那么对应的指定初始化器必须指名该匿名union的其中一个成员。
struct C
{
union
{
int a;
const char* p;
};
int x;
} c = {
.a = 1, .x = 3}; // 以 1 初始化 c.a 并且以 3 初始化 c.x
- 乱序的指定初始化、嵌套的指定初始化、指定初始化器与常规初始化器的混合,以及数组的指定初始化在 C 编程语言中受支持,但在 C++ 不允许。
struct A {
int x, y; };
struct B {
struct A a; };
struct A a = {
.y = 1, .x = 2}; // C 中合法,C++ 中非法(乱序)
int arr[3] = {
[1] = 5}; // C 中合法,C++ 中非法(数组)
struct B b = {
.a.x = 0}; // C 中合法,C++ 中非法(嵌套)
struct A a = {
.x = 1, 2}; // C 中合法,C++ 中非法(混合)
总结
变量的初始化是C++标准的一个重要组成部分,C++初始化按照是否存在拷贝,可将初始化划分为直接初始化和拷贝初始化;其他初始化均可以归类为此两大类。本文围绕此分类标准,分别介绍了:C++98标准的直接初始化,拷贝初始化,聚合初始化,圆括号初始化;C++11标准的列表初始化和initializer_list; C++17标准的强制省略拷贝优化,列表初始化类型推导,聚合初始化扩展(允许基类);C++20标准的聚合初始化的扩展,指定初始化器初始化。