ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

构造函数详解:默认、委托、转换与 explicit 到底写在哪

构造函数详解:默认、委托、转换与 explicit 到底写在哪 std::vectorint v{10}和std::vectorint v(10)编译都不报错可前者得到「一个元素值是 10」后者得到「10 个元素全是 0」。同一个类、同样的参数只换了括号语义就完全变了。这是花括号初始化brace initialization带来的副作用也是构造函数这块最容易被忽略的地方。这篇把构造函数按「默认、委托、转换、explicit」四件事拆开讲重点放在哪些写法会悄悄调用构造函数以及explicit该加在哪个位置。默认构造什么时候自动有什么时候被抑制只要你不写任何构造函数编译器就给你补一个隐式默认构造implicitly-declared default constructor一旦你写了任意一个构造函数哪怕只是带参的这个隐式版本就不再生成。这也是「类里加了带参构造std::vectorT v(n)突然编译不过」的根因。std::vector的resize/vector(n)需要T可默认构造。 default和 delete是两种显式表态// ctor_default.cpp — 编译: g -stdc17 -Wall -O2 ctor_default.cpp -o cd#includecstdio#includetype_traitsstructImplicit{};// 编译器隐式生成默认构造structExplicitDefault{ExplicitDefault()default;};structNoDefault{// 有带参构造隐式默认构造被抑制intv_;explicitNoDefault(intv):v_(v){}};structDeletedDefault{DeletedDefault()delete;// 显式删除彻底禁止默认构造explicitDeletedDefault(intv):v_(v){}intv_;};templatetypenameTvoidreport(constchar*name){std::printf(%-16s 可默认构造 %s\n,name,std::is_default_constructibleT::value?是:否);}intmain(){reportImplicit(Implicit);reportExplicitDefault(ExplicitDefault);reportNoDefault(NoDefault);reportDeletedDefault(DeletedDefault);std::printf(DeletedDefault 可用带参构造 %s\n,std::is_constructibleDeletedDefault,int::value?是:否);}Implicit 可默认构造 是 ExplicitDefault 可默认构造 是 NoDefault 可默认构造 否 DeletedDefault 可默认构造 否 DeletedDefault 可用带参构造 是这里容易误以为 default和「什么都不写」完全等价。效果确实一样但意图不同写出来等于告诉读代码的人「我确实要默认构造」。更实用的是 delete。它在编译期就切断了这条路径比运行期抛异常throw std::logic_error好得多类型不满足要求的错误在编译阶段就暴露而不是等某次运行才炸。官方文档Default constructors — cppreference · Functions default/ delete— cppreference委托构造消灭初始化逻辑的重复一个类有多个构造函数时很容易写出「三份几乎一样的成员初始化代码」。C11 的委托构造delegating constructor解决这件事让一个构造函数在自己的初始化列表里调用另一个构造函数。构造 Config a; ── 委托构造的执行顺序 ────────────────────────── ① Config() : Config(default.conf, 8080) ← 委托者的「初始化列表」 │ 委托先执行目标构造函数 ▼ ② Config(path, port) : path_(path), port_(port) ← 成员只在这里初始化 └─ 主构造函数体执行 │ ▼ ③ 回到 Config() 的函数体 └─ 此时对象已完全构造可以放心用 this-成员 要点类自身的成员只在「目标构造函数」里初始化一次 委托者的函数体里不能再给成员写初始化列表那是编译错误。// ctor_delegate.cpp — 编译: g -stdc17 -Wall -O2 ctor_delegate.cpp -o cdl#includecstdio#includestringclassConfig{public:Config():Config(default.conf,8080){// 委托给下面的主构造std::printf( 委托者 [无参] 的函数体\n);}explicitConfig(conststd::stringpath):Config(path,8080){// 同样委托std::printf( 委托者 [单参] 的函数体\n);}Config(conststd::stringpath,intport):path_(path),port_(port){std::printf( 主构造 %s:%d\n,path_.c_str(),port_);}voiddump()const{std::printf( - %s:%d\n,path_.c_str(),port_);}private:std::string path_;intport_;};intmain(){std::printf([无参构造]\n);Config a;a.dump();std::printf([单参构造]\n);Configb(std::string(db));b.dump();std::printf([两参构造]\n);Configc(net.conf,9090);c.dump();}[无参构造] 主构造 default.conf:8080 委托者 [无参] 的函数体 - default.conf:8080 [单参构造] 主构造 db:8080 委托者 [单参] 的函数体 - db:8080 [两参构造] 主构造 net.conf:9090 - net.conf:9090输出顺序就是执行顺序主构造先跑完含成员初始化委托者的函数体最后跑。所以委托者的函数体里可以安全地使用已初始化好的成员上例里dump()拿到的就是完整对象。反过来委托之后不能再写成员初始化列表Config() : Config(...), path_(x) {}是编译错误因为成员已经初始化过了。官方文档Constructorsdelegating constructors— cppreference转换构造函数与 explicit隐式转换的意外「只用一个实参就能调用」的构造函数叫转换构造函数converting constructor。它给了编译器一张「隐式转换许可证」而这张许可证经常被无意中用掉// ctor_implicit.cpp — 编译: g -stdc17 -Wall -O2 ctor_implicit.cpp -o ci#includecstdio#includestringvoidlog_message(boolenabled){std::printf(误入 log_message(bool): %s\n,enabled?true:false);}voidlog_message(conststd::stringtext){std::printf(正确 log_message(string): %s\n,text.c_str());}intmain(){log_message(disk full);// 想传字符串实际传了「指针是否非空」log_message(std::string(disk full));// 显式构造才走到想要的重载}误入 log_message(bool): true 正确 log_message(string): disk fullconst char*转bool是标准转换转std::string是用户定义转换。前者优先级更高于是log_message(disk full)静默地走进了bool版本。写的人以为在传错误信息实际传的是「指针非空」。这类 bug 编译通过、运行不崩是最难查的那一类。把单参数构造函数加上explicitCore Guidelines C.46就能把隐式转换这条路堵死代价只是调用方要多写两个字符// 反例的对照写法这段本身不编译故意展示 explicit 拦下的两种写法classPort{public:explicitPort(intvalue):value_(value){}private:intvalue_;};voidlisten_on(constPortp);voiddemo(){// listen_on(3000); // 错误explicit 之后不接受隐式转换// Port p 3000; // 错误拷贝初始化不允许 explicit 构造函数Portq(3000);// 正确直接初始化Port r{3000};// 正确直接列表初始化listen_on(q);}官方文档explicit specifier — cppreference · C Core Guidelines C.46explicit operator bool()if (ptr)为什么安全转换运算符也能是explicit而且这件事直接决定了「if (ptr)为什么不会出事」。典型场景是自己写的句柄类型语境explicit operator bool()是否生效原因if (h)/while (h)生效条件位置做的是上下文转换contextual conversion!h/h x/h ? a : b生效同上都属于条件语境bool b h;不生效编译错误需要真正的隐式转换int n h 1;不生效编译错误不会退化成一个整数去参与算术也就是说explicit operator bool保留了「判断有效/无效」这个合法用途同时切断了「被当成 0/1 参与运算」这条隐患路径。std::ifstream、std::unique_ptr都是这么设计的所以if (file)是安全且清晰的写法。花括号初始化{}和()不是一回事回到开头那个坑。std::vector同时有vector(size_type n)和vector(std::initializer_listT)而花括号会优先选initializer_list版本// ctor_braces.cpp — 编译: g -stdc17 -Wall -O2 ctor_braces.cpp -o cb#includecstdio#includevectorintmain(){std::vectorintbraces{3,5};// initializer_list两个元素std::vectorintparens(3,5);// (count, value)三个 5std::vectorintone_brace{10};// 一个元素 10std::vectorintone_paren(10);// 十个 0std::printf(v{3,5} size%zu 元素:,braces.size());for(intv:braces)std::printf( %d,v);std::printf(\n);std::printf(v(3,5) size%zu 元素:,parens.size());for(intv:parens)std::printf( %d,v);std::printf(\n);std::printf(v{10} size%zu 元素:,one_brace.size());for(intv:one_brace)std::printf( %d,v);std::printf(\n);std::printf(v(10) size%zu 元素:,one_paren.size());for(intv:one_paren)std::printf( %d,v);std::printf(\n);}v{3,5} size2 元素: 3 5 v(3,5) size3 元素: 5 5 5 v{10} size1 元素: 10 v(10) size10 元素: 0 0 0 0 0 0 0 0 0 0写法选中的构造函数结果风险std::vectorint v{3, 5}vector(std::initializer_listint)[3, 5]2 个元素与下面一行语义完全不同std::vectorint v(3, 5)vector(size_type, const T)[5, 5, 5]3 个元素—std::vectorint v{10}vector(std::initializer_listint)[10]1 个元素想开 10 个槽就错了std::vectorint v(10)vector(size_type)10 个0—这个规则不限于容器。任何同时声明了std::initializer_list重载和其他重载的类花括号都会把initializer_list版本顶到最前面。哪怕要经过隐式转换只要实参能转成initializer_listT编译器就优先选它// ctor_initlist.cpp — 编译: g -stdc17 -Wall -O2 ctor_initlist.cpp -o cil#includecstdio#includeinitializer_listclassPolicy{public:Policy(intlevel,boolstrict):kind_(两个参数),level_(level),strict_(strict){}Policy(std::initializer_listintlevels)// 优先级更高:kind_(initializer_list),level_(0),strict_(false){for(intv:levels)level_v;}voiddump()const{std::printf(选中 %s: level_%d strict_%d\n,kind_,level_,static_castint(strict_));}private:constchar*kind_;intlevel_;boolstrict_;};intmain(){Policyp(1,true);std::printf(圆括号 - );p.dump();Policy q{1,true};std::printf(花括号 - );q.dump();}圆括号 - 选中 两个参数: level_1 strict_1 花括号 - 选中 initializer_list: level_2 strict_0Policy q{1, true}里true被提升成1initializer_list版本把两者求和strict_悄悄变成false。所以有两条实用建议。第一类里一旦有initializer_list重载就要在文档里写清楚别指望调用方自己猜。第二调用方在「参数数量有意义」时优先用圆括号只有在真的列举元素时才用花括号。官方文档List initialization — cppreference · std::initializer_list — cppreference三种构造 explicit 转换运算符把上面几件事串到一个类里委托构造消灭重复explicit拦住隐式转换explicit operator bool提供安全的有效性判断。// ctor_full.cpp — 编译: g -stdc17 -Wall -O2 ctor_full.cpp -o cf#includecstdionamespace{constexprintkDefaultPort8080;constexprintkMaxPort65535;}classPort{public:Port():Port(kDefaultPort){// 委托给主构造std::printf( [Port] 无参构造的函数体\n);}explicitPort(intvalue):value_(value){// explicit只接显式构造std::printf( [Port] 主构造 value%d\n,value_);}// explicit operator bool只在条件语境if / ! / ?:里生效explicitoperatorbool()constnoexcept{returnvalue_0value_kMaxPort;}intvalue()constnoexcept{returnvalue_;}private:intvalue_;};voidlisten_on(constPortp){std::printf(listen on %d\n,p.value());}intmain(){constPort def;// 走委托构造 - 8080constPortcustom(3000);constPortinvalid(0);listen_on(def);listen_on(custom);std::printf(默认端口 %d\n,def.value());std::printf(3000 可用 %s\n,custom?是:否);std::printf(0 可用 %s\n,invalid?是:否);}[Port] 主构造 value8080 [Port] 无参构造的函数体 [Port] 主构造 value3000 [Port] 主构造 value0 listen on 8080 listen on 3000 默认端口 8080 3000 可用 是 0 可用 否三处细节值得留意。第一const Port def;只写了委托者输出里却是「主构造」在先委托是先跑目标构造。第二Port(3000)必须显式写Port p 3000;会被explicit拦下端口这种东西不该被一个数字悄悄转换出来。第三custom ? 是 : 否用的是上下文转换explicit operator bool允许它但int n custom;不允许这正是if (stream)安全的原因。延伸阅读Constructors — cppreference —— 成员初始化列表、委托构造、异常下的析构规则都在这一页Default constructors — cppreference —— 什么时候隐式声明、什么时候被抑制逐条列出explicit specifier — cppreference —— 含「上下文转换」的完整语境清单List initialization — cppreference —— 花括号优先级规则的权威出处std::initializer_list — cppreference —— 它只是「指向数组的轻量视图」底层数组的生命周期比对象短C Core Guidelines —— C.46「单参数构造函数加 explicit」、C.48「优先用{}做默认初始化」收个尾默认构造会被任何一个别的构造函数挤掉委托构造让多个构造函数共用一份初始化逻辑单参数构造一律加explicit花括号永远优先选initializer_list重载。四件事里第三件最容易被忘代价却是最大的listen_on(3000)这种调用编译得过跑起来也不报错只是行为跟你以为的不一样。{}和()的差别记住v{3,5}和v(3,5)是两个结果就够了。剩下的都是从这一条往外推。
返回列表