Effective C++ 条款04:确定对象被使用前已先被初始化
读取未初始化的值会导致未定义行为。C++ 的初始化规则复杂且微妙,理解它们是写出正确、高效代码的关键。
开篇引言
C++ 的初始化规则可能是所有主流编程语言中最复杂的。有些变量会被自动初始化,有些不会;构造函数体内的"赋值"和初始化列表中的"初始化"有着本质区别;不同编译单元中的全局对象初始化顺序更是充满陷阱。
Scott Meyers 在《Effective C++》第四条告诫我们:确定对象被使用前已先被初始化。 这不仅是正确性的要求,更是性能优化的起点。
C++ 的初始化迷局
内置类型的初始化差异
cpp
void initialization_demo() {
int x; // 未初始化!值不确定(可能是任意垃圾值)
int y = 10; // 显式初始化
int z(20); // 直接初始化
int w{30}; // 列表初始化(C++11,最推荐)
// 数组
int arr1[5]; // 元素未初始化
int arr2[5] = {}; // 所有元素初始化为 0
int arr3[5] = {1}; // 第一个为 1,其余为 0
}
关键规则:
| 场景 | 是否自动初始化 | 说明 |
|---|---|---|
| 全局/命名空间作用域 | 是 | 初始化为 0 |
| 局部内置类型 | 否 | 值不确定 |
| 类成员(内置类型) | 取决于初始化列表 | 否则未定义 |
| 堆分配对象 | 否 | new 不初始化内置类型 |
new int() |
是 | 值初始化为 0 |
类类型的初始化
cpp
class Widget {
public:
Widget() { std::cout << "Widget constructed\n"; }
};
void class_init_demo() {
Widget w1; // 调用默认构造函数
Widget w2 = Widget(); // 值初始化
Widget w3{}; // 列表初始化(C++11)
Widget* pw1 = new Widget; // 默认构造函数
Widget* pw2 = new Widget(); // 值初始化
Widget* pw3 = new Widget{}; // 列表初始化
}
初始化 vs. 赋值:效率的本质差异
这是本条款最核心的知识点。很多开发者误以为构造函数体内的赋值就是初始化,实际上两者有着天壤之别。
错误的写法:构造函数内赋值
cpp
class PhoneNumber {
public:
PhoneNumber(const std::string& name, const std::string& number) {
// 这些不是初始化,而是赋值!
theName = name; // 先默认构造,再赋值
theNumber = number; // 先默认构造,再赋值
}
private:
std::string theName;
std::string theNumber;
};
实际执行流程:
- 进入构造函数体之前,所有成员已经通过默认构造函数初始化
- 构造函数体内的
=是赋值操作 ,调用operator=
对于 std::string,这意味着:
- 先调用默认构造函数创建空字符串
- 再调用拷贝赋值运算符赋入新值
正确的写法:成员初始化列表
cpp
class PhoneNumber {
public:
PhoneNumber(const std::string& name, const std::string& number)
: theName(name), // 直接拷贝构造
theNumber(number) // 直接拷贝构造
{
// 构造函数体可以为空
}
private:
std::string theName;
std::string theNumber;
};
实际执行流程:
- 成员通过拷贝构造函数直接初始化
- 没有额外的默认构造和赋值操作
效率对比
cpp
#include <iostream>
#include <string>
class EfficiencyDemo {
public:
// 方法 A:赋值(低效)
EfficiencyDemo(const std::string& s) {
data = s; // 默认构造 + 拷贝赋值 = 2 次操作
}
// 方法 B:初始化列表(高效)
EfficiencyDemo(const std::string& s) : data(s) {
// 拷贝构造 = 1 次操作
}
private:
std::string data;
};
| 方式 | std::string 操作次数 |
性能 |
|---|---|---|
| 构造函数内赋值 | 默认构造 + 拷贝赋值 = 2 次 | 低效 |
| 成员初始化列表 | 拷贝构造 = 1 次 | 高效 |
对于复杂对象,这种差异可能更加显著。
必须使用初始化列表的情况
有些成员只能用初始化列表,无法在构造函数体内赋值:
cpp
class MustInitList {
public:
MustInitList(int val, int& ref)
: constMember(val), // const 成员必须初始化
refMember(ref), // 引用成员必须初始化
baseValue(val) // 没有默认构造的基类/成员必须初始化
{}
private:
const int constMember;
int& refMember;
class Base {
public:
explicit Base(int x) : value(x) {}
private:
int value;
};
Base baseValue;
};
成员初始化顺序
初始化顺序规则
成员初始化顺序由它们在类中的声明顺序决定,与初始化列表中的顺序无关!
cpp
class OrderDemo {
public:
// 警告:初始化列表顺序与声明顺序不一致!
OrderDemo(int val)
: y(val), // 先写 y,但...
x(y) // x 实际上先初始化!此时 y 还未初始化!
{}
private:
int x; // x 先声明,先初始化
int y; // y 后声明,后初始化
};
// 正确写法:保持初始化列表与声明顺序一致
class OrderDemoCorrect {
public:
OrderDemoCorrect(int val)
: x(val), // 与声明顺序一致
y(val)
{}
private:
int x;
int y;
};
一些编译器(如 GCC、Clang)会在初始化列表顺序与声明顺序不一致时发出警告。建议始终开启这类警告并视为错误处理。
基类与成员的初始化顺序
cpp
class Base {
public:
Base() { std::cout << "Base\n"; }
};
class Member {
public:
Member() { std::cout << "Member\n"; }
};
class Derived : public Base {
public:
Derived() : member(), baseExtra(0) {
std::cout << "Derived\n";
}
private:
Member member;
int baseExtra;
};
// 构造顺序:
// 1. Base(基类)
// 2. Member(成员)
// 3. baseExtra(成员)
// 4. Derived 构造函数体
完整初始化顺序:
- 基类构造函数(从最远的祖先开始)
- 成员变量构造函数(按声明顺序)
- 自身构造函数体
跨编译单元的全局对象初始化
静态初始化顺序问题(Static Initialization Order Fiasco)
这是 C++ 中最臭名昭著的陷阱之一:
cpp
// file1.cpp
extern int globalB;
int globalA = globalB + 1; // 如果 globalB 还没初始化?
// file2.cpp
extern int globalA;
int globalB = globalA + 1; // 如果 globalA 还没初始化?
不同编译单元中的非局部静态对象初始化顺序是未定义的!
解决方案:Singleton 模式(局部静态对象)
cpp
// 推荐:使用局部静态对象替代全局对象
class FileSystem {
public:
static FileSystem& instance() {
static FileSystem fs; // C++11 起线程安全
return fs;
}
std::size_t numDisks() const { return 5; }
private:
FileSystem() = default;
~FileSystem() = default;
FileSystem(const FileSystem&) = delete;
FileSystem& operator=(const FileSystem&) = delete;
};
// 使用
class Directory {
public:
Directory() {
// 安全:FileSystem 在首次使用时初始化
std::size_t disks = FileSystem::instance().numDisks();
}
};
C++11 起,局部静态对象的初始化是线程安全的(Meyers' Singleton)。
现代 C++ 的初始化改进
C++11 列表初始化
cpp
class ModernInit {
public:
ModernInit()
: x{0}, // 列表初始化
y{3.14}, // 防止窄化转换
name{"default"}
{}
ModernInit(int val)
: x{val},
y{static_cast<double>(val)},
name{"initialized"}
{}
private:
int x;
double y;
std::string name;
};
// 列表初始化防止窄化
// ModernInit m{3.14}; // 编译错误!double 到 int 窄化
默认成员初始化器(C++11)
cpp
class DefaultInit {
private:
int x = 0; // 默认成员初始化器
std::string name = "default"; // 如果初始化列表未提供,使用此值
std::vector<int> data{1, 2, 3}; // 列表初始化
public:
DefaultInit() = default; // x=0, name="default", data={1,2,3}
DefaultInit(int val)
: x(val) // x=val, name="default", data={1,2,3}
{}
DefaultInit(int val, const std::string& s)
: x(val), name(s) // x=val, name=s, data={1,2,3}
{}
};
委托构造函数(C++11)
cpp
class DelegatingCtor {
public:
// 主构造函数
DelegatingCtor(int x, int y, const std::string& label)
: x_(x), y_(y), label_(label)
{}
// 委托构造函数
DelegatingCtor()
: DelegatingCtor(0, 0, "origin") // 委托给主构造函数
{}
DelegatingCtor(int x, int y)
: DelegatingCtor(x, y, "point") // 委托给主构造函数
{}
private:
int x_;
int y_;
std::string label_;
};
实际应用场景
场景 1:资源管理类(RAII)
cpp
#include <fstream>
#include <string>
class FileHandler {
public:
explicit FileHandler(const std::string& path)
: file_(path), // 直接初始化文件流
isOpen_(false),
bytesRead_(0)
{
isOpen_ = file_.is_open(); // 构造函数体内做状态检查
}
~FileHandler() {
if (file_.is_open()) {
file_.close();
}
}
bool isOpen() const { return isOpen_; }
private:
std::ifstream file_;
bool isOpen_;
std::size_t bytesRead_;
};
场景 2:继承体系中的初始化
cpp
class Shape {
public:
explicit Shape(const std::string& color) : color_(color) {}
virtual ~Shape() = default;
private:
std::string color_;
};
class Circle : public Shape {
public:
Circle(const std::string& color, double radius)
: Shape(color), // 先初始化基类
radius_(radius), // 再初始化成员
area_(3.14159 * radius * radius)
{}
double area() const { return area_; }
private:
double radius_;
double area_;
};
场景 3:PIMPL 惯用法中的初始化
cpp
// widget.h
class Widget {
public:
Widget();
~Widget();
Widget(Widget&& rhs) noexcept;
Widget& operator=(Widget&& rhs) noexcept;
// ...
private:
class Impl; // 前置声明
std::unique_ptr<Impl> pImpl; // 智能指针管理实现
};
// widget.cpp
class Widget::Impl {
public:
Impl() : data(0), name("default") {}
int data;
std::string name;
std::vector<double> values;
};
Widget::Widget()
: pImpl(std::make_unique<Impl>()) // 在实现文件中初始化
{}
Widget::~Widget() = default; // 必须在 .cpp 中定义,因为 Impl 在此处完整
总结与建议
核心要点
-
为内置型对象进行手工初始化,因为 C++ 不保证初始化它们。
-
构造函数最好使用成员初始化列表,而不要在构造函数本体内使用赋值操作。初始化列表列出的成员变量,其排列顺序应该和它们在 class 中的声明次序相同。
-
为免除"跨编译单元之初始化次序"问题,请以局部静态对象替换非局部静态对象。
初始化最佳实践 checklist
| 场景 | 建议 |
|---|---|
| 内置类型局部变量 | 始终显式初始化 |
| 类成员 | 优先使用初始化列表 |
| const / 引用成员 | 必须使用初始化列表 |
| 无默认构造的成员 | 必须使用初始化列表 |
| 默认成员初始化 | 使用 C++11 默认成员初始化器 |
| 全局静态对象 | 使用局部静态对象替代 |
| 初始化列表顺序 | 与类内声明顺序保持一致 |
经典名言
确定对象被使用前已先被初始化。
这条守则看似简单,实则蕴含了 C++ 对象模型的核心知识。掌握初始化与赋值的区别,理解初始化顺序规则,是写出高效、正确 C++ 代码的必经之路。
参考阅读:
- 《Effective C++》Scott Meyers,条款 04
- 《C++ Primer》关于构造函数和初始化的章节
- 《Effective Modern C++》关于列表初始化的条款
系列预告: 下一篇将深入解析条款 05------了解 C++ 默默编写并调用哪些函数,揭开编译器自动生成成员函数的神秘面纱。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。