成员初始化列表与初始化顺序陷阱:按声明顺序,不是书写顺序

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 虽然默认构造不分配,但赋值的扩容逻辑照样跑)。所以正确的心态不是「省一次函数调用」,而是省掉一次可能带堆分配、带内存拷贝的完整对象构造。

社区里常说的「构造函数体里不要写赋值」,指的就是这件事,而不是风格洁癖。

官方文档:std::initializer_list(成员初始化的语义)--- cppreference

把顺序纪律落进一个类里

下面的类同时踩到前面所有要点:一个没有默认构造的成员、一个 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 成员、引用成员、没有默认构造的成员必须写在初始化列表里,其余成员也建议写进去,因为初始化列表是一次直接构造,函数体赋值则是默认构造加一次赋值。

真正能长期坚持的纪律只有一条,而且简单到不像建议:声明什么顺序,初始化列表就写什么顺序。

相关推荐
91刘仁德1 小时前
传输层协议深度拆解:端口寻址、UDP 报文与 TCP 可靠性机制全解析
linux·网络·c++·网络协议·tcp/ip·udp·c
Liuchang09111 小时前
洛谷 P2670 [NOIP 2015 普及组] 扫雷游戏
c++·算法·游戏
顶点多余1 小时前
C/C++常见面试题
开发语言·c++
HugoStudio_SWAN1 小时前
洛谷 B4575 [GESP202609 二级] 直角三角形——临场定义理解与边界质疑
c++·学习·程序人生·算法
(Charon)1 小时前
【C++面试】右值引用与移动语义:std::move到底做了什么
开发语言·c++
红豆诗人2 小时前
C++二叉搜索树:查找、插入和删除为什么这样写
数据结构·c++·算法
夏天的清晨2 小时前
C++入门学习
开发语言·c++·学习
汉克老师2 小时前
GESP2026年9月认证C++七级( 第二部分判断题(1~10题)精讲
c++·gesp·小学生·学c++编程
Dream it possible!2 小时前
蓝桥杯_翻转_C++
c++·算法·蓝桥杯