C++ 初始化方式与类型分类
一、常见的初始化方式
与赋值不同,C++ 的初始化规则较为复杂。C++ 提供了 5 种常见的初始化形式:
int a; // 默认初始化(无初始值)
// 传统初始化形式:
int b = 5; // 拷贝初始化(等号后给出初始值)
int c ( 6 ); // 直接初始化(圆括号内给出初始值,显式调用匹配的构造函数)
// 现代初始化形式(推荐):
int d { 7 }; // 直接列表初始化(大括号内给出初始值)
int e {}; // 值初始化(空大括号)
自 C++17 起,复制初始化、直接初始化和直接列表初始化在大多数情况下行为相同。关键差异在于列表初始化禁止缩窄转换。
1、默认初始化
不提供初始值时称为默认初始化。大多数情况下,默认初始化不执行任何初始化操作,变量持有不确定值(即"垃圾值")。
int a; // 局部变量,值不确定
全局或静态变量是例外:它们会被自动初始化为 0(指针为 nullptr)。
2、复制初始化
等号后提供初始值的形式称为复制初始化。这种形式继承自 C 语言。
int width = 5; // 复制初始化,width 值为 5
复制初始化将等号右侧的值复制到左侧新创建的变量中。早期 C++ 中,复制初始化对某些复杂类型效率较低,但 C++17 已修复了大部分问题。
3、直接初始化
圆括号内提供初始值的形式称为直接初始化。
int width ( 5 ); // 直接初始化,width 值为 5
直接初始化最初用于高效初始化复杂对象。现代 C++ 中,列表初始化已成为首选方式,但直接初始化在某些特定场景仍有用武之地。
4、列表初始化
使用大括号初始化对象称为列表初始化(也称为统一初始化或大括号初始化)。列表初始化有两种形式:
int width { 5 }; // 直接列表初始化(推荐)
int height = { 6 }; // 复制列表初始化(较少使用)
列表初始化的优势: - 语法统一,适用于基本类型、容器和类 - 大括号语法清晰,易于识别是初始化操作 - 禁止缩窄转换,编译器必须产生诊断信息
缩窄转换示例:
int w1 { 4.5 }; // 编译错误:列表初始化禁止缩窄转换
int w2 = 4.5; // 编译通过:w2 值为 4(精度丢失)
int w3 (4.5); // 编译通过:w3 值为 4(精度丢失)
用浮点数 4.5 初始化整数变量属于缩窄转换,列表初始化会强制编译器报错,而复制初始化和直接初始化则可能仅给出警告或静默截断。
5、值初始化
空大括号初始化变量称为值初始化。大多数情况下,值初始化会将变量初始化为 0(或该类型最接近零的值)。
int width {}; // 值初始化,width 值为 0
建议
列表初始化(包括值初始化)是现代 C++ 推荐的初始化方式:
- 适用于大多数场景,一致性最好
- 禁止缩窄转换,防止意外的数据丢失
- 支持用值列表初始化(如 std::vector<int> v{1, 2, 3};)
二、Most Vexing Parse 陷阱
int a(); 在函数内部不是初始化,而是函数声明。
编译器将其解析为:声明一个名为 a 的函数,不接受参数,返回 int。
int a(); // 函数声明,不是变量定义
只有在特定语境下,圆括号才触发值初始化:
int* p = new int(); // 值初始化,*p 为 0
int x = int(); // 值初始化,x 为 0
struct MyStruct {
int a;
MyStruct() : a() {} // 成员初始化列表,a 为 0
};
避免方法:使用大括号或不带括号的形式。
| 目标 | 错误写法 | 正确写法 |
|---|---|---|
| 值初始化(设为 0) | int a(); |
int a{}; 或 int a = int(); |
| 默认初始化(随机值) | 无 | int a; |
1、圆括号初始化的问题
C++ 规定:任何可以被解析为函数声明的语句,都会被优先解析为函数声明。
Timer timer(); // 声明函数,不是初始化对象
TimeKeeper t(Timer()); // 声明函数,参数是函数指针
使用大括号可消除歧义:
Timer timer{}; // 正确:初始化对象
圆括号的其他问题: 1. 允许隐式缩窄转换:数据丢失时仅警告,不报错 2. 语法局限性:无法直接初始化容器元素
std::vector<int> v(10, 5); // 10 个元素,值都是 5
std::vector<int> v{10, 5}; // 2 个元素,值分别是 10 和 5
2、必须使用圆括号的情况
调用特定构造函数:类同时有普通构造函数和 std::initializer_list 构造函数时,大括号优先匹配后者。
std::vector<int> v1(100); // 100 个 0
std::vector<int> v2{100}; // 1 个元素,值是 100
使用 auto 推导:某些标准中 auto x{1}; 可能被推导为 std::initializer_list<int>。
三、类型分类
C++ 中,对象、引用、函数、表达式都有“类型”这个属性,类型规定了对这些实体可以进行的操作,以及如何解释这些实体在内存中的含义。类型可从两个维度分类:类型来源和内存语义。
1、类型来源标准
关注类型的来源:语言自带还是基于已有类型组合。
基础类型(fundamental):
- 算术类型:int、float、char、bool
- 特殊类型:void、nullptr_t
- 特点:语言的原子类型,不依赖其他类型
复合类型(compound):
- 指针 int*、引用 int&、数组 int[]
- 类/结构体 class、struct、联合 union
- 特点:基于基础类型构建
2、内存语义标准
关注类型在内存中的表现和 CPU 处理方式。
标量类型(scalar):
- 包括:算术类型、指针类型、枚举类型、nullptr_t
- 物理特性:不可再分,通常能装进寄存器
- 拷贝方式:位拷贝(Bitwise Copy),无深浅之分
聚合类型(aggregate): - 包括:数组、符合条件的结构体/类 - 物理特性:由多个成员排列,内存中是连续区域 - 拷贝方式:处理多个成员,可能涉及复杂生命周期管理
3、类型分类对照
| 类型示例 | 构造标准 | 内存语义 | 拷贝特性 |
|---|---|---|---|
int |
基础类型 | 标量 | 位拷贝,原子操作 |
int* |
复合类型 | 标量 | 位拷贝,原子操作 |
int& |
复合类型 | N/A(别名) | 不支持拷贝/重绑定 |
int[10] |
复合类型 | 聚合 | 无法直接整体赋值 |
std::string |
复合类型 | 非标量 | 涉及深拷贝/资源管理 |
其他分类方式可以参考Type和Overview of data objects。注意,Primitive Built-in Types” 是《C++ Primer》作者为了教学方便而采用的逻辑分类,而非 C++ 国际标准(ISO/IEC 14882)中的官方严谨用语。
四、指针与引用的特殊性
1、指针的特殊性
指针在类型系统中的位置特殊:从构造标准看是复合类型,从内存语义看是标量类型。
拷贝行为:拷贝指针和拷贝 int 相同,都是将若干字节从一个地址 mov 到另一个地址。
无深浅拷贝之分:指针拷贝永远只是地址复制。深拷贝是类类型的行为,在拷贝指针后额外执行指向目标的构造操作。
初始化规则一致:
- 全局定义:int 自动清零为 0,指针自动清零为 nullptr
- 局部定义:int 是随机值,指针是随机地址(野指针)
标量类型的物理特性:在程序加载时,.bss 段将所有未初始化的全局变量清零。指针在物理上是存储地址的整数,清零即代表 0x0(nullptr)。类类型即便在全局,也必须调用构造函数完成初始化。
2、引用的特殊地位
引用不是基础类型:引用属于复合类型,不能独立存在。必须定义"指向某种类型的引用"(如 int&)。
引用不是变量:引用不是对象,是已存在对象的别名。
- 变量占据内存空间,有自己的地址
- 引用是已存在对象的第二个名字
- 对引用取地址 &ref,得到的是被引用对象的地址
底层实现:编译器通常将引用实现为指针常量 type* const。
int a = 10;
int &ref = a; // 底层等同于 int* const p = &a;
ref = 20; // 底层等同于 *p = 20;
引用必须初始化:引用在语义上不是独立实体,必须在诞生时绑定到合法对象。
- 一旦绑定,终身不变
- 无法让引用在后续改为引用另一个对象
- 底层解释:int* const p 中的 const 修饰指针本身,地址一旦写入不可更改
3、引用与指针对比
| 特性 | 内置类型变量(int) |
指针变量(int*) |
引用(int&) |
|---|---|---|---|
| 分类 | 基础类型 | 复合类型 | 复合类型 |
| 是否是对象 | 是 | 是 | 否(语义上是别名) |
| 是否有独立地址 | 是 | 是 | 否(取地址得到原对象地址) |
| 是否必须初始化 | 否(局部变量为随机值) | 否(局部变量为随机值) | 是(强制要求) |
| 可否重新绑定 | 赋值改变值 | 是 | 否 |
五、定义位置对初始化的影响
内置类型:
全局作用域或静态生命周期:
- 执行静态初始化
- 自动初始化为 0(指针为 nullptr)
- 在程序加载时处理
局部作用域: - 不进行任何初始化 - 值未定义(随机) - 直接读取导致崩溃或逻辑错误
自定义类:
无论在哪定义,默认初始化都会尝试调用默认构造函数。
定义位置影响成员变量:
1. 类定义了默认构造函数:运行构造函数代码
2. 类使用编译器生成的默认构造函数:
- 自定义类成员:递归调用它们的默认构造函数
- 内置类型成员:遵循普通变量规则
- 对象在函数内部定义:int 成员是随机值
- 对象在全局定义:int 成员是 0
1、初始化规则总结
| 变量类型 | 定义位置 | 初始化结果 |
|---|---|---|
内置类型(int、double) |
全局/static | 0 |
内置类型(int、double) |
函数内部(局部) | 随机值 |
自定义类(class、struct) |
任何位置 | 调用默认构造函数 |
| 类的内置类型成员 | 随对象位置而定 | 全局为 0,局部为随机值(除非有显式默认值) |
使用类内初始值避免依赖定义位置:
class MyData {
int x = 0; // 无论对象在全局还是局部,x 永远是 0
double y{0.0};
};
通过这种方式,将初始化权利从"内存位置"转移到"代码定义",增强程序健壮性。
2、性能考虑
传值 vs 传引用:
- 标量类型(int、float*):直接传值通常最快,它们是寄存器中的一个数
- 非标量类型(std::vector、大型 struct):传值意味着大量内存拷贝,必须考虑传引用
初始化开销:
- 标量类型:初始化廉价(mov 指令)
- 非标量类型:初始化可能涉及动态内存分配、循环赋值,开销显著
数组与指针的退化:数组是聚合类型(一排数据),指针是标量类型(一个地址)。数组名传递给函数时退化为指针,将聚合类型的首地址当做标量传递,这是 C/C++ 高效的原因。
本文是 C++ 初始化系列文章的第一篇,主要介绍初始化方式和类型系统。下一篇C++ 初始化与赋值的底层机制将深入探讨初始化与赋值的本质区别及底层实现。

浙公网安备 33010602011771号