std::vector<int> v{10}和std::vector<int> v(10)编译都不报错,可前者得到「一个元素,值是 10」,后者得到「10 个元素,全是 0」。同一个类、同样的参数,只换了括号,语义就完全变了。
这是花括号初始化(brace initialization)带来的副作用,也是构造函数这块最容易被忽略的地方。这篇把构造函数按「默认、委托、转换、explicit」四件事拆开讲,重点放在哪些写法会悄悄调用构造函数,以及explicit该加在哪个位置。
默认构造:什么时候自动有,什么时候被抑制
只要你不写任何构造函数,编译器就给你补一个隐式默认构造(implicitly-declared default constructor);一旦你写了任意一个构造函数(哪怕只是带参的),这个隐式版本就不再生成。
这也是「类里加了带参构造,std::vector<T> v(n)突然编译不过」的根因。std::vector的resize/vector(n)需要T可默认构造。
= default和= delete是两种显式表态:
// ctor_default.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_default.cpp -o cd#include<cstdio>#include<type_traits>structImplicit{};// 编译器隐式生成默认构造structExplicitDefault{ExplicitDefault()=default;};structNoDefault{// 有带参构造,隐式默认构造被抑制intv_;explicitNoDefault(intv):v_(v){}};structDeletedDefault{DeletedDefault()=delete;// 显式删除:彻底禁止默认构造explicitDeletedDefault(intv):v_(v){}intv_;};template<typenameT>voidreport(constchar*name){std::printf("%-16s 可默认构造 = %s\n",name,std::is_default_constructible<T>::value?"是":"否");}intmain(){report<Implicit>("Implicit");report<ExplicitDefault>("ExplicitDefault");report<NoDefault>("NoDefault");report<DeletedDefault>("DeletedDefault");std::printf("DeletedDefault 可用带参构造 = %s\n",std::is_constructible<DeletedDefault,int>::value?"是":"否");}Implicit 可默认构造 = 是 ExplicitDefault 可默认构造 = 是 NoDefault 可默认构造 = 否 DeletedDefault 可默认构造 = 否 DeletedDefault 可用带参构造 = 是这里容易误以为= default和「什么都不写」完全等价。效果确实一样,但意图不同:写出来等于告诉读代码的人「我确实要默认构造」。
更实用的是= delete。它在编译期就切断了这条路径,比运行期抛异常(throw std::logic_error)好得多:类型不满足要求的错误在编译阶段就暴露,而不是等某次运行才炸。
官方文档:Default constructors — cppreference · Functions(
= default/= delete)— cppreference
委托构造:消灭初始化逻辑的重复
一个类有多个构造函数时,很容易写出「三份几乎一样的成员初始化代码」。C++11 的委托构造(delegating constructor)解决这件事:让一个构造函数在自己的初始化列表里调用另一个构造函数。
构造 Config a; ── 委托构造的执行顺序 ────────────────────────── ① Config() : Config("default.conf", 8080) ← 委托者的「初始化列表」 │ 委托:先执行目标构造函数 ▼ ② Config(path, port) : path_(path), port_(port) ← 成员只在这里初始化 └─ 主构造函数体执行 │ ▼ ③ 回到 Config() 的函数体 └─ 此时对象已完全构造,可以放心用 this->成员 要点:类自身的成员只在「目标构造函数」里初始化一次; 委托者的函数体里不能再给成员写初始化列表(那是编译错误)。// ctor_delegate.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_delegate.cpp -o cdl#include<cstdio>#include<string>classConfig{public:Config():Config("default.conf",8080){// 委托给下面的主构造std::printf(" 委托者 [无参] 的函数体\n");}explicitConfig(conststd::string&path):Config(path,8080){// 同样委托std::printf(" 委托者 [单参] 的函数体\n");}Config(conststd::string&path,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") {}是编译错误),因为成员已经初始化过了。
官方文档:Constructors(delegating constructors)— cppreference
转换构造函数与 explicit:隐式转换的意外
「只用一个实参就能调用」的构造函数叫转换构造函数(converting constructor)。它给了编译器一张「隐式转换许可证」,而这张许可证经常被无意中用掉:
// ctor_implicit.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_implicit.cpp -o ci#include<cstdio>#include<string>voidlog_message(boolenabled){std::printf("误入 log_message(bool): %s\n",enabled?"true":"false");}voidlog_message(conststd::string&text){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 编译通过、运行不崩,是最难查的那一类。
把单参数构造函数加上explicit(Core Guidelines C.46)就能把隐式转换这条路堵死,代价只是调用方要多写两个字符:
// 反例的对照写法(这段本身不编译,故意展示 explicit 拦下的两种写法)classPort{public:explicitPort(intvalue):value_(value){}private:intvalue_;};voidlisten_on(constPort&p);voiddemo(){// listen_on(3000); // 错误:explicit 之后不接受隐式转换// Port p = 3000; // 错误:拷贝初始化不允许 explicit 构造函数Portq(3000);// 正确:直接初始化Port r{3000};// 正确:直接列表初始化listen_on(q);}官方文档:explicit specifier — cppreference · C++ Core Guidelines C.46
explicit 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_list<T>),而花括号会优先选initializer_list版本:
// ctor_braces.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_braces.cpp -o cb#include<cstdio>#include<vector>intmain(){std::vector<int>braces{3,5};// initializer_list:两个元素std::vector<int>parens(3,5);// (count, value):三个 5std::vector<int>one_brace{10};// 一个元素 10std::vector<int>one_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} size=2 元素: 3 5 v(3,5) size=3 元素: 5 5 5 v{10} size=1 元素: 10 v(10) size=10 元素: 0 0 0 0 0 0 0 0 0 0| 写法 | 选中的构造函数 | 结果 | 风险 |
|---|---|---|---|
std::vector<int> v{3, 5} | vector(std::initializer_list<int>) | [3, 5],2 个元素 | 与下面一行语义完全不同 |
std::vector<int> v(3, 5) | vector(size_type, const T&) | [5, 5, 5],3 个元素 | — |
std::vector<int> v{10} | vector(std::initializer_list<int>) | [10],1 个元素 | 想开 10 个槽就错了 |
std::vector<int> v(10) | vector(size_type) | 10 个0 | — |
这个规则不限于容器。任何同时声明了std::initializer_list重载和其他重载的类,花括号都会把initializer_list版本顶到最前面。哪怕要经过隐式转换,只要实参能转成initializer_list<T>,编译器就优先选它:
// ctor_initlist.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_initlist.cpp -o cil#include<cstdio>#include<initializer_list>classPolicy{public:Policy(intlevel,boolstrict):kind_("两个参数"),level_(level),strict_(strict){}Policy(std::initializer_list<int>levels)// 优先级更高: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_cast<int>(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被提升成1,initializer_list版本把两者求和,strict_悄悄变成false。所以有两条实用建议。第一,类里一旦有initializer_list重载,就要在文档里写清楚,别指望调用方自己猜。第二,调用方在「参数数量有意义」时优先用圆括号,只有在真的列举元素时才用花括号。
官方文档:List initialization — cppreference · std::initializer_list — cppreference
三种构造 + explicit 转换运算符
把上面几件事串到一个类里:委托构造消灭重复,explicit拦住隐式转换,explicit operator bool提供安全的有效性判断。
// ctor_full.cpp — 编译: g++ -std=c++17 -Wall -O2 ctor_full.cpp -o cf#include<cstdio>namespace{constexprintkDefaultPort=8080;constexprintkMaxPort=65535;}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_>0&&value_<=kMaxPort;}intvalue()constnoexcept{returnvalue_;}private:intvalue_;};voidlisten_on(constPort&p){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] 主构造 value=8080 [Port] 无参构造的函数体 [Port] 主构造 value=3000 [Port] 主构造 value=0 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)是两个结果就够了。剩下的都是从这一条往外推。