a_(b_), b_(x) 这种初始化列表,在评审里特别容易被放过去:两行都写对了成员名、都写对了实参,编译器也不一定报错。但如果 b_ 的声明在 a_ 后面,那 a_(b_) 读的就是一个还没初始化 的 b_。这不是「值不对」,是未定义行为(undefined behavior)。这篇把成员初始化列表的规则、顺序陷阱和性能差异一次讲清,结尾给一条可以直接照抄的纪律。
顺序写反的初始化列表
先看这段「看起来没问题」的代码:
cpp
// 反例,不要这么写(读取未初始化成员是未定义行为)
struct Bad {
int a_;
int b_;
Bad(int x) : b_(x), a_(b_) {} // 列表写 b_ 在前,但声明里 a_ 在前
};
写的人以为「初始化列表会按我写的顺序执行」,于是 b_(x) 先赋值,a_(b_) 就能拿到 x。但 C++ 规定的是另一回事:非静态数据成员一律按声明顺序初始化,初始化列表的书写顺序只决定「每个成员拿哪个实参」,不决定先后。这里 a_ 先初始化,它的实参表达式 b_ 还是个没初始化的变量。UB。
铁律:声明顺序决定初始化顺序
把顺序可视化一下:
text
成员初始化顺序 = 声明顺序(与初始化列表的书写顺序无关)
────────────────────────────────────────────────────────────────
class Ordered {
public:
Ordered() : second_("second"), first_("first") {} ← 列表把 second_ 写在前面
private:
Probe first_; ← 声明 #1:真正先初始化
Probe second_; ← 声明 #2:真正后初始化
};
实际执行顺序:
① first_ 取列表里写给 first_ 的实参 "first"
② second_ 取列表里写给 second_ 的实参 "second"
③ 构造函数体
结论:列表里的「位置」只决定每个成员拿哪个实参,
不决定谁先初始化 ------ 谁先初始化只看声明顺序。
────────────────────────────────────────────────────────────────
如果某个成员的初始化表达式引用了另一个成员,
就等于赌「那个成员已经初始化好了」:
赌赢靠声明顺序,赌输就是 UB。
用可运行的代码验证一遍,让每个成员在构造时打印自己的名字:
cpp
// ctor_order.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_order.cpp -o co
#include <cstdio>
struct Probe {
const char* name_;
explicit Probe(const char* name) : name_(name) {
std::printf(" 构造 %s\n", name_);
}
};
class Ordered {
public:
// 初始化列表故意按「声明顺序的反序」写
Ordered() : second_("second"), first_("first") {
std::printf("完成: first_=%s second_=%s\n", first_.name_, second_.name_);
}
private:
Probe first_; // 声明在前 ------ 它一定先初始化
Probe second_; // 声明在后
};
int main() {
Ordered o;
}
text
构造 first
构造 second
完成: first_=first second_=second
输出顺序是 first 在前,和初始化列表的书写顺序相反,和声明顺序一致。这就是全部规则,短得像一句废话,坑却全在里面。
编译器其实会提醒你
gcc / clang 都有一个专门的警告:-Wreorder(属于 -Wall)。上面这个类在 -Wall 下会输出:
text
// gcc 13.2.0,-Wall 的真实输出(节选,行号来自编译器原文)
prog.cc: In constructor 'Ordered::Ordered()':
prog.cc:16:11: warning: 'Ordered::b_' will be initialized after [-Wreorder]
prog.cc:15:11: warning: 'Probe Ordered::a_' [-Wreorder]
prog.cc:11:5: warning: when initialized here [-Wreorder]
用的是「某成员将被初始化在......之后」这种措辞,把声明顺序和实际初始化位置摆在一起,一眼就能看出错位。所以写作纪律的第一条是:把 -Wreorder 当成错误看,别当噪音。很多人把它当噪音关掉,然后花一下午调一个本可以提前十分钟发现的问题。
官方文档:Constructors(初始化顺序)--- cppreference · Data members(声明顺序)--- cppreference
哪些成员「必须」写在初始化列表里
不是所有成员都能在构造函数体里补上。下面这几类只能在初始化列表里初始化:
cpp
// init_list_must.cpp --- 编译: g++ -std=c++17 -Wall -O2 init_list_must.cpp -o ilm
#include <cstdio>
#include <string>
class Endpoint {
public:
explicit Endpoint(const std::string& url) : url_(url) {} // 故意不提供默认构造
const std::string& url() const { return url_; }
private:
std::string url_;
};
class Session {
public:
Session(const Endpoint& ep, int port, const std::string& name)
: endpoint_(ep), port_(port), name_(name) {}
void dump() const {
std::printf("Session{url=%s, port=%d, name=%s}\n",
endpoint_.url().c_str(), port_, name_.c_str());
}
private:
Endpoint endpoint_; // 没有默认构造 ------ 必须初始化
const int port_; // const ------ 必须初始化
const std::string& name_; // 引用 ------ 必须初始化
};
int main() {
const std::string who = "miao";
Session s(Endpoint("https://db.internal"), 5432, who);
s.dump();
std::printf("sizeof(Session) = %zu\n", sizeof(Session));
}
text
Session{url=https://db.internal, port=5432, name=miao}
sizeof(Session) = 48
三个成员一个都躲不掉:Endpoint 没有默认构造,函数体里写 endpoint_ = ep; 会因为「默认构造不存在」直接编译失败;port_ 是 const int,赋不了值;name_ 是引用,引用必须在出生时就绑定。顺带看一眼 sizeof:Endpoint 里的 std::string 占 32 字节,加 const int 的 4 字节(对齐到 8)和引用的 8 字节,正好 48。
| 成员类型 | 能否只在构造函数体里赋值 | 说明 |
|---|---|---|
const 成员 |
不能 | 必须在初始化列表里给初值 |
| 引用成员 | 不能 | 引用必须出生即绑定 |
| 没有默认构造的类类型 | 不能 | 体内赋值会要求「先默认构造再赋值」,第一步就失败 |
| 数组成员 | 不能 | C++11 起可在初始化列表里用 {...} |
| 基类子对象 | 能,但绕 | 体内只能调基类的 operator=,多一次默认构造 |
| 有默认构造的类类型 | 能 | 代价是「默认构造 + 赋值」两次操作 |
内置类型(int / double) |
能 | 代价同上,且忘写就是不确定值 |
官方文档:Member initializer list --- cppreference · C++ Core Guidelines C.47
效率:初始化列表少一次操作
「能在体内赋值」不等于「应该体内赋值」。看一个记录每次操作的类:
cpp
// init_list_cost.cpp --- 编译: g++ -std=c++17 -Wall -O2 init_list_cost.cpp -o ilc
#include <cstdio>
struct Tracked {
int v_;
Tracked() : v_(0) { std::printf(" 默认构造\n"); }
explicit Tracked(int v) : v_(v) { std::printf(" 带参构造(%d)\n", v_); }
Tracked(const Tracked& o) : v_(o.v_) { std::printf(" 拷贝构造(%d)\n", v_); }
Tracked& operator=(const Tracked& o) {
v_ = o.v_; std::printf(" 拷贝赋值(%d)\n", v_); return *this;
}
};
class ViaBody {
public:
explicit ViaBody(const Tracked& t) { m_ = t; } // 函数体内赋值
int value() const { return m_.v_; }
private:
Tracked m_;
};
class ViaList {
public:
explicit ViaList(const Tracked& t) : m_(t) {} // 初始化列表直接构造
int value() const { return m_.v_; }
private:
Tracked m_;
};
int main() {
Tracked src(7);
std::printf("构造函数体内赋值:\n");
ViaBody b(src);
std::printf("成员初始化列表:\n");
ViaList l(src);
std::printf("结果 %d %d\n", b.value(), l.value());
}
text
带参构造(7)
构造函数体内赋值:
默认构造
拷贝赋值(7)
成员初始化列表:
拷贝构造(7)
结果 7 7
两条路径的差别:
| 写法 | 发生的操作 | 操作次数 | 备注 |
|---|---|---|---|
ViaBody(const Tracked& t) { m_ = t; } |
默认构造 m_ → 拷贝赋值 |
2 次 | 默认构造出来的值立刻被覆盖,纯浪费 |
ViaList(const Tracked& t) : m_(t) {} |
直接拷贝构造 m_ |
1 次 | 一步到位 |
对 Tracked 这种只有一个 int 的类,两次操作和一次操作差别可以忽略;但如果 m_ 是 std::string、std::vector<T>、或者一个几十 KB 的缓冲区,那次「默认构造」可能意味着一次堆分配 (比如 std::string 在旧实现里默认构造也会分配 SSO 缓冲之外的空间,std::vector 虽然默认构造不分配,但赋值的扩容逻辑照样跑)。所以正确的心态不是「省一次函数调用」,而是省掉一次可能带堆分配、带内存拷贝的完整对象构造。
社区里常说的「构造函数体里不要写赋值」,指的就是这件事,而不是风格洁癖。
把顺序纪律落进一个类里
下面的类同时踩到前面所有要点:一个没有默认构造的成员、一个 const 成员、一个引用成员,而且声明顺序和初始化列表顺序完全一致 。这是最省事的纪律:写完声明,照着声明顺序写列表,-Wreorder 永远不会响。
cpp
// init_list_full.cpp --- 编译: g++ -std=c++17 -Wall -O2 init_list_full.cpp -o ilf
#include <cstdio>
#include <string>
class Logger {
public:
explicit Logger(const std::string& tag) : tag_(tag) {
std::printf(" 构造 Logger(%s)\n", tag_.c_str());
}
const std::string& tag() const { return tag_; }
private:
std::string tag_;
};
class Task {
public:
// 初始化列表顺序 = 下面的声明顺序:logger_ -> name_ -> retries_ -> parent_
Task(const std::string& name, int retries, const std::string& parent)
: logger_("task"), name_(name), retries_(retries), parent_(parent) {}
void dump() const {
std::printf("Task{logger=%s, name=%s, retries=%d, parent=%s}\n",
logger_.tag().c_str(), name_.c_str(), retries_, parent_.c_str());
}
private:
Logger logger_; // 没有默认构造 ------ 必须初始化
std::string name_; // 有默认构造,但列表里直接构造更省
const int retries_; // const ------ 必须初始化
const std::string& parent_; // 引用 ------ 必须初始化
};
int main() {
const std::string owner = "scheduler";
Task t("flush", 3, owner);
t.dump();
std::printf("sizeof(Task) = %zu\n", sizeof(Task));
}
text
构造 Logger(task)
Task{logger=task, name=flush, retries=3, parent=scheduler}
sizeof(Task) = 80
Task 的四个成员一个都逃不掉初始化列表,而且顺序和声明一致,编译器一声不响。sizeof 也顺便印证了成员布局:Logger(内含 std::string,32 字节)+ std::string(32)+ const int(4,补齐到 8)+ 引用(8)= 80 字节。
延伸阅读
- Constructors --- cppreference ------ 「Initialization order」一节是这篇全部结论的出处,注意它明确写了「按 declaration order」
- Data members --- cppreference ------ 成员声明顺序如何决定布局与初始化顺序
- std::initializer_list --- cppreference ------ 用
{...}初始化数组/容器成员时的语义 - C++ Core Guidelines ------ C.47「按声明顺序定义并初始化成员变量」,还有 C.48 关于
{}做默认初始化的建议 - Compiler Explorer ------ 想看「默认构造 + 赋值」和「直接构造」的汇编差异时用它,
-O2下两者通常会被优化成一样,能直观看到「编译器替你省掉了什么」
收个尾
非静态成员一律按声明顺序初始化,初始化列表的书写位置只决定「谁拿哪个实参」。所以「用另一个成员给当前成员赋初值」这种写法极其危险,读到未初始化的值就是 UB,好消息是 -Wreorder(-Wall 自带)会提前把错位指出来。
const 成员、引用成员、没有默认构造的成员必须写在初始化列表里,其余成员也建议写进去,因为初始化列表是一次直接构造,函数体赋值则是默认构造加一次赋值。
真正能长期坚持的纪律只有一条,而且简单到不像建议:声明什么顺序,初始化列表就写什么顺序。