ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

成员初始化列表与初始化顺序陷阱:按声明顺序,不是书写顺序 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 -stdc17 -Wall -O2 ctor_order.cpp -o co#includecstdiostructProbe{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 -stdc17 -Wall -O2 init_list_must.cpp -o ilm#includecstdio#includestringclassEndpoint{public:explicitEndpoint(conststd::stringurl):url_(url){}// 故意不提供默认构造conststd::stringurl()const{returnurl_;}private:std::string url_;};classSession{public:Session(constEndpointep,intport,conststd::stringname):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::stringname_;// 引用 —— 必须初始化};intmain(){conststd::string whomiao;Sessions(Endpoint(https://db.internal),5432,who);s.dump();std::printf(sizeof(Session) %zu\n,sizeof(Session));}Session{urlhttps://db.internal, port5432, namemiao} sizeof(Session) 48三个成员一个都躲不掉Endpoint没有默认构造函数体里写endpoint_ ep;会因为「默认构造不存在」直接编译失败port_是const int赋不了值name_是引用引用必须在出生时就绑定。顺带看一眼sizeofEndpoint里的std::string占 32 字节加const int的 4 字节对齐到 8和引用的 8 字节正好 48。成员类型能否只在构造函数体里赋值说明const成员不能必须在初始化列表里给初值引用成员不能引用必须出生即绑定没有默认构造的类类型不能体内赋值会要求「先默认构造再赋值」第一步就失败数组成员不能C11 起可在初始化列表里用{...}基类子对象能但绕体内只能调基类的operator多一次默认构造有默认构造的类类型能代价是「默认构造 赋值」两次操作内置类型int/double能代价同上且忘写就是不确定值官方文档Member initializer list — cppreference · C Core Guidelines C.47效率初始化列表少一次操作「能在体内赋值」不等于「应该体内赋值」。看一个记录每次操作的类// init_list_cost.cpp — 编译: g -stdc17 -Wall -O2 init_list_cost.cpp -o ilc#includecstdiostructTracked{intv_;Tracked():v_(0){std::printf( 默认构造\n);}explicitTracked(intv):v_(v){std::printf( 带参构造(%d)\n,v_);}Tracked(constTrackedo):v_(o.v_){std::printf( 拷贝构造(%d)\n,v_);}Trackedoperator(constTrackedo){v_o.v_;std::printf( 拷贝赋值(%d)\n,v_);return*this;}};classViaBody{public:explicitViaBody(constTrackedt){m_t;}// 函数体内赋值intvalue()const{returnm_.v_;}private:Tracked m_;};classViaList{public:explicitViaList(constTrackedt):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::vectorT、或者一个几十 KB 的缓冲区那次「默认构造」可能意味着一次堆分配比如std::string在旧实现里默认构造也会分配 SSO 缓冲之外的空间std::vector虽然默认构造不分配但赋值的扩容逻辑照样跑。所以正确的心态不是「省一次函数调用」而是省掉一次可能带堆分配、带内存拷贝的完整对象构造。社区里常说的「构造函数体里不要写赋值」指的就是这件事而不是风格洁癖。官方文档std::initializer_list成员初始化的语义— cppreference把顺序纪律落进一个类里下面的类同时踩到前面所有要点一个没有默认构造的成员、一个const成员、一个引用成员而且声明顺序和初始化列表顺序完全一致。这是最省事的纪律写完声明照着声明顺序写列表-Wreorder永远不会响。// init_list_full.cpp — 编译: g -stdc17 -Wall -O2 init_list_full.cpp -o ilf#includecstdio#includestringclassLogger{public:explicitLogger(conststd::stringtag):tag_(tag){std::printf( 构造 Logger(%s)\n,tag_.c_str());}conststd::stringtag()const{returntag_;}private:std::string tag_;};classTask{public:// 初始化列表顺序 下面的声明顺序logger_ - name_ - retries_ - parent_Task(conststd::stringname,intretries,conststd::stringparent):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::stringparent_;// 引用 —— 必须初始化};intmain(){conststd::string ownerscheduler;Taskt(flush,3,owner);t.dump();std::printf(sizeof(Task) %zu\n,sizeof(Task));}构造 Logger(task) Task{loggertask, nameflush, retries3, parentscheduler} sizeof(Task) 80Task的四个成员一个都逃不掉初始化列表而且顺序和声明一致编译器一声不响。sizeof也顺便印证了成员布局Logger内含std::string32 字节std::string32const int4补齐到 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成员、引用成员、没有默认构造的成员必须写在初始化列表里其余成员也建议写进去因为初始化列表是一次直接构造函数体赋值则是默认构造加一次赋值。真正能长期坚持的纪律只有一条而且简单到不像建议声明什么顺序初始化列表就写什么顺序。
返回列表