news 2026/10/5 6:52:10

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
成员初始化列表与初始化顺序陷阱:按声明顺序,不是书写顺序

a_(b_), b_(x)这种初始化列表,在评审里特别容易被放过去:两行都写对了成员名、都写对了实参,编译器也不一定报错。但如果b_的声明在a_后面,那a_(b_)读的就是一个还没初始化的b_。这不是「值不对」,是未定义行为(undefined behavior)。这篇把成员初始化列表的规则、顺序陷阱和性能差异一次讲清,结尾给一条可以直接照抄的纪律。

顺序写反的初始化列表

先看这段「看起来没问题」的代码:

// 反例,不要这么写(读取未初始化成员是未定义行为)structBad{inta_;intb_;Bad(intx):b_(x),a_(b_){}// 列表写 b_ 在前,但声明里 a_ 在前};

写的人以为「初始化列表会按我写的顺序执行」,于是b_(x)先赋值,a_(b_)就能拿到x。但 C++ 规定的是另一回事:非静态数据成员一律按声明顺序初始化,初始化列表的书写顺序只决定「每个成员拿哪个实参」,不决定先后。这里a_先初始化,它的实参表达式b_还是个没初始化的变量。UB。

铁律:声明顺序决定初始化顺序

把顺序可视化一下:

成员初始化顺序 = 声明顺序(与初始化列表的书写顺序无关) ──────────────────────────────────────────────────────────────── class Ordered { public: Ordered() : second_("second"), first_("first") {} ← 列表把 second_ 写在前面 private: Probe first_; ← 声明 #1:真正先初始化 Probe second_; ← 声明 #2:真正后初始化 }; 实际执行顺序: ① first_ 取列表里写给 first_ 的实参 "first" ② second_ 取列表里写给 second_ 的实参 "second" ③ 构造函数体 结论:列表里的「位置」只决定每个成员拿哪个实参, 不决定谁先初始化 —— 谁先初始化只看声明顺序。 ──────────────────────────────────────────────────────────────── 如果某个成员的初始化表达式引用了另一个成员, 就等于赌「那个成员已经初始化好了」: 赌赢靠声明顺序,赌输就是 UB。

用可运行的代码验证一遍,让每个成员在构造时打印自己的名字:

// ctor_order.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_order.cpp -o co#include<cstdio>structProbe{constchar*name_;explicitProbe(constchar*name):name_(name){std::printf(" 构造 %s\n",name_);}};classOrdered{public:// 初始化列表故意按「声明顺序的反序」写Ordered():second_("second"),first_("first"){std::printf("完成: first_=%s second_=%s\n",first_.name_,second_.name_);}private:Probe first_;// 声明在前 —— 它一定先初始化Probe second_;// 声明在后};intmain(){Ordered o;}
构造 first 构造 second 完成: first_=first second_=second

输出顺序是first在前,和初始化列表的书写顺序相反,和声明顺序一致。这就是全部规则,短得像一句废话,坑却全在里面。

编译器其实会提醒你

gcc / clang 都有一个专门的警告:-Wreorder(属于-Wall)。上面这个类在-Wall下会输出:

// 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

哪些成员「必须」写在初始化列表里

不是所有成员都能在构造函数体里补上。下面这几类只能在初始化列表里初始化:

// init_list_must.cpp — 编译: g++ -std=c++17 -Wall -O2 init_list_must.cpp -o ilm#include<cstdio>#include<string>classEndpoint{public:explicitEndpoint(conststd::string&url):url_(url){}// 故意不提供默认构造conststd::string&url()const{returnurl_;}private:std::string url_;};classSession{public:Session(constEndpoint&ep,intport,conststd::string&name):endpoint_(ep),port_(port),name_(name){}voiddump()const{std::printf("Session{url=%s, port=%d, name=%s}\n",endpoint_.url().c_str(),port_,name_.c_str());}private:Endpoint endpoint_;// 没有默认构造 —— 必须初始化constintport_;// const —— 必须初始化conststd::string&name_;// 引用 —— 必须初始化};intmain(){conststd::string who="miao";Sessions(Endpoint("https://db.internal"),5432,who);s.dump();std::printf("sizeof(Session) = %zu\n",sizeof(Session));}
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

效率:初始化列表少一次操作

「能在体内赋值」不等于「应该体内赋值」。看一个记录每次操作的类:

// init_list_cost.cpp — 编译: g++ -std=c++17 -Wall -O2 init_list_cost.cpp -o ilc#include<cstdio>structTracked{intv_;Tracked():v_(0){std::printf(" 默认构造\n");}explicitTracked(intv):v_(v){std::printf(" 带参构造(%d)\n",v_);}Tracked(constTracked&o):v_(o.v_){std::printf(" 拷贝构造(%d)\n",v_);}Tracked&operator=(constTracked&o){v_=o.v_;std::printf(" 拷贝赋值(%d)\n",v_);return*this;}};classViaBody{public:explicitViaBody(constTracked&t){m_=t;}// 函数体内赋值intvalue()const{returnm_.v_;}private:Tracked m_;};classViaList{public:explicitViaList(constTracked&t):m_(t){}// 初始化列表直接构造intvalue()const{returnm_.v_;}private:Tracked m_;};intmain(){Trackedsrc(7);std::printf("构造函数体内赋值:\n");ViaBodyb(src);std::printf("成员初始化列表:\n");ViaListl(src);std::printf("结果 %d %d\n",b.value(),l.value());}
带参构造(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永远不会响。

// init_list_full.cpp — 编译: g++ -std=c++17 -Wall -O2 init_list_full.cpp -o ilf#include<cstdio>#include<string>classLogger{public:explicitLogger(conststd::string&tag):tag_(tag){std::printf(" 构造 Logger(%s)\n",tag_.c_str());}conststd::string&tag()const{returntag_;}private:std::string tag_;};classTask{public:// 初始化列表顺序 = 下面的声明顺序:logger_ -> name_ -> retries_ -> parent_Task(conststd::string&name,intretries,conststd::string&parent):logger_("task"),name_(name),retries_(retries),parent_(parent){}voiddump()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_;// 有默认构造,但列表里直接构造更省constintretries_;// const —— 必须初始化conststd::string&parent_;// 引用 —— 必须初始化};intmain(){conststd::string owner="scheduler";Taskt("flush",3,owner);t.dump();std::printf("sizeof(Task) = %zu\n",sizeof(Task));}
构造 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成员、引用成员、没有默认构造的成员必须写在初始化列表里,其余成员也建议写进去,因为初始化列表是一次直接构造,函数体赋值则是默认构造加一次赋值。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 6:45:20

Assessing the Value of Visual Input: A Benchmark of Multimodal Large Language Models for Robotic ...

文章主要内容和创新点 主要内容 本文旨在评估视觉输入对多模态大型语言模型(MLLMs)在机器人路径规划任务中的作用,通过构建全面的基准测试展开研究。研究团队在2D网格环境(模拟简化的机器人规划场景)中对15个多模态大型语言模型进行了评估,比较了仅文本输入与文本+视觉…

作者头像 李华