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 是两种显式表态:
cpp
// ctor_default.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_default.cpp -o cd
#include <cstdio>
#include <type_traits>
struct Implicit {}; // 编译器隐式生成默认构造
struct ExplicitDefault { ExplicitDefault() = default; };
struct NoDefault { // 有带参构造,隐式默认构造被抑制
int v_;
explicit NoDefault(int v) : v_(v) {}
};
struct DeletedDefault {
DeletedDefault() = delete; // 显式删除:彻底禁止默认构造
explicit DeletedDefault(int v) : v_(v) {}
int v_;
};
template <typename T>
void report(const char* name) {
std::printf("%-16s 可默认构造 = %s\n", name,
std::is_default_constructible<T>::value ? "是" : "否");
}
int main() {
report<Implicit>("Implicit");
report<ExplicitDefault>("ExplicitDefault");
report<NoDefault>("NoDefault");
report<DeletedDefault>("DeletedDefault");
std::printf("DeletedDefault 可用带参构造 = %s\n",
std::is_constructible<DeletedDefault, int>::value ? "是" : "否");
}
text
Implicit 可默认构造 = 是
ExplicitDefault 可默认构造 = 是
NoDefault 可默认构造 = 否
DeletedDefault 可默认构造 = 否
DeletedDefault 可用带参构造 = 是
这里容易误以为 = default 和「什么都不写」完全等价。效果确实一样,但意图不同:写出来等于告诉读代码的人「我确实要默认构造」。
更实用的是 = delete。它在编译期就切断了这条路径,比运行期抛异常(throw std::logic_error)好得多:类型不满足要求的错误在编译阶段就暴露,而不是等某次运行才炸。
官方文档:Default constructors --- cppreference · Functions(
= default/= delete)--- cppreference
委托构造:消灭初始化逻辑的重复
一个类有多个构造函数时,很容易写出「三份几乎一样的成员初始化代码」。C++11 的委托构造(delegating constructor)解决这件事:让一个构造函数在自己的初始化列表里调用另一个构造函数。
text
构造 Config a; ── 委托构造的执行顺序 ──────────────────────────
① Config() : Config("default.conf", 8080) ← 委托者的「初始化列表」
│ 委托:先执行目标构造函数
▼
② Config(path, port) : path_(path), port_(port) ← 成员只在这里初始化
└─ 主构造函数体执行
│
▼
③ 回到 Config() 的函数体
└─ 此时对象已完全构造,可以放心用 this->成员
要点:类自身的成员只在「目标构造函数」里初始化一次;
委托者的函数体里不能再给成员写初始化列表(那是编译错误)。
cpp
// ctor_delegate.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_delegate.cpp -o cdl
#include <cstdio>
#include <string>
class Config {
public:
Config() : Config("default.conf", 8080) { // 委托给下面的主构造
std::printf(" 委托者 [无参] 的函数体\n");
}
explicit Config(const std::string& path) : Config(path, 8080) { // 同样委托
std::printf(" 委托者 [单参] 的函数体\n");
}
Config(const std::string& path, int port) : path_(path), port_(port) {
std::printf(" 主构造 %s:%d\n", path_.c_str(), port_);
}
void dump() const { std::printf(" -> %s:%d\n", path_.c_str(), port_); }
private:
std::string path_;
int port_;
};
int main() {
std::printf("[无参构造]\n");
Config a; a.dump();
std::printf("[单参构造]\n");
Config b(std::string("db")); b.dump();
std::printf("[两参构造]\n");
Config c("net.conf", 9090); c.dump();
}
text
[无参构造]
主构造 default.conf:8080
委托者 [无参] 的函数体
-> default.conf:8080
[单参构造]
主构造 db:8080
委托者 [单参] 的函数体
-> db:8080
[两参构造]
主构造 net.conf:9090
-> net.conf:9090
输出顺序就是执行顺序:主构造先跑完(含成员初始化),委托者的函数体最后跑 。所以委托者的函数体里可以安全地使用已初始化好的成员,上例里 dump() 拿到的就是完整对象。反过来,委托之后不能再写成员初始化列表(Config() : Config(...), path_("x") {} 是编译错误),因为成员已经初始化过了。
转换构造函数与 explicit:隐式转换的意外
「只用一个实参就能调用」的构造函数叫转换构造函数(converting constructor)。它给了编译器一张「隐式转换许可证」,而这张许可证经常被无意中用掉:
cpp
// ctor_implicit.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_implicit.cpp -o ci
#include <cstdio>
#include <string>
void log_message(bool enabled) {
std::printf("误入 log_message(bool): %s\n", enabled ? "true" : "false");
}
void log_message(const std::string& text) {
std::printf("正确 log_message(string): %s\n", text.c_str());
}
int main() {
log_message("disk full"); // 想传字符串,实际传了「指针是否非空」
log_message(std::string("disk full")); // 显式构造,才走到想要的重载
}
text
误入 log_message(bool): true
正确 log_message(string): disk full
const char* 转 bool 是标准转换 ,转 std::string 是用户定义转换 。前者优先级更高,于是 log_message("disk full") 静默地走进了 bool 版本。写的人以为在传错误信息,实际传的是「指针非空」。这类 bug 编译通过、运行不崩,是最难查的那一类。
把单参数构造函数加上 explicit(Core Guidelines C.46)就能把隐式转换这条路堵死,代价只是调用方要多写两个字符:
cpp
// 反例的对照写法(这段本身不编译,故意展示 explicit 拦下的两种写法)
class Port {
public:
explicit Port(int value) : value_(value) {}
private:
int value_;
};
void listen_on(const Port& p);
void demo() {
// listen_on(3000); // 错误:explicit 之后不接受隐式转换
// Port p = 3000; // 错误:拷贝初始化不允许 explicit 构造函数
Port q(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 版本:
cpp
// ctor_braces.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_braces.cpp -o cb
#include <cstdio>
#include <vector>
int main() {
std::vector<int> braces{3, 5}; // initializer_list:两个元素
std::vector<int> parens(3, 5); // (count, value):三个 5
std::vector<int> one_brace{10}; // 一个元素 10
std::vector<int> one_paren(10); // 十个 0
std::printf("v{3,5} size=%zu 元素:", braces.size());
for (int v : braces) std::printf(" %d", v);
std::printf("\n");
std::printf("v(3,5) size=%zu 元素:", parens.size());
for (int v : parens) std::printf(" %d", v);
std::printf("\n");
std::printf("v{10} size=%zu 元素:", one_brace.size());
for (int v : one_brace) std::printf(" %d", v);
std::printf("\n");
std::printf("v(10) size=%zu 元素:", one_paren.size());
for (int v : one_paren) std::printf(" %d", v);
std::printf("\n");
}
text
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>,编译器就优先选它:
cpp
// ctor_initlist.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_initlist.cpp -o cil
#include <cstdio>
#include <initializer_list>
class Policy {
public:
Policy(int level, bool strict)
: kind_("两个参数"), level_(level), strict_(strict) {}
Policy(std::initializer_list<int> levels) // 优先级更高
: kind_("initializer_list"), level_(0), strict_(false) {
for (int v : levels) level_ += v;
}
void dump() const {
std::printf("选中 %s: level_=%d strict_=%d\n",
kind_, level_, static_cast<int>(strict_));
}
private:
const char* kind_;
int level_;
bool strict_;
};
int main() {
Policy p(1, true); std::printf("圆括号 -> "); p.dump();
Policy q{1, true}; std::printf("花括号 -> "); q.dump();
}
text
圆括号 -> 选中 两个参数: level_=1 strict_=1
花括号 -> 选中 initializer_list: level_=2 strict_=0
Policy q{1, true} 里 true 被提升成 1,initializer_list 版本把两者求和,strict_ 悄悄变成 false。所以有两条实用建议。第一,类里一旦有 initializer_list 重载,就要在文档里写清楚,别指望调用方自己猜。第二,调用方在「参数数量有意义」时优先用圆括号,只有在真的列举元素时才用花括号。
官方文档:List initialization --- cppreference · std::initializer_list --- cppreference
三种构造 + explicit 转换运算符
把上面几件事串到一个类里:委托构造消灭重复,explicit 拦住隐式转换,explicit operator bool 提供安全的有效性判断。
cpp
// ctor_full.cpp --- 编译: g++ -std=c++17 -Wall -O2 ctor_full.cpp -o cf
#include <cstdio>
namespace {
constexpr int kDefaultPort = 8080;
constexpr int kMaxPort = 65535;
}
class Port {
public:
Port() : Port(kDefaultPort) { // 委托给主构造
std::printf(" [Port] 无参构造的函数体\n");
}
explicit Port(int value) : value_(value) { // explicit:只接显式构造
std::printf(" [Port] 主构造 value=%d\n", value_);
}
// explicit operator bool:只在条件语境(if / ! / ?:)里生效
explicit operator bool() const noexcept {
return value_ > 0 && value_ <= kMaxPort;
}
int value() const noexcept { return value_; }
private:
int value_;
};
void listen_on(const Port& p) {
std::printf("listen on %d\n", p.value());
}
int main() {
const Port def; // 走委托构造 -> 8080
const Port custom(3000);
const Port invalid(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 ? "是" : "否");
}
text
[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) 是两个结果就够了。剩下的都是从这一条往外推。