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

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") {} 是编译错误),因为成员已经初始化过了。

官方文档:Constructors(delegating constructors)--- cppreference

转换构造函数与 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) 安全的原因。

延伸阅读

收个尾

默认构造会被任何一个别的构造函数挤掉,委托构造让多个构造函数共用一份初始化逻辑,单参数构造一律加 explicit,花括号永远优先选 initializer_list 重载。四件事里第三件最容易被忘,代价却是最大的:listen_on(3000) 这种调用编译得过,跑起来也不报错,只是行为跟你以为的不一样。

{} 和 () 的差别,记住 v{3,5} 和 v(3,5) 是两个结果就够了。剩下的都是从这一条往外推。

相关推荐
纪念 2291 小时前
C++ string(一)
android·开发语言·c++
纪念 2291 小时前
C++算法(二)
开发语言·c++·算法
AC赳赳老秦10 小时前
采集行为合规自检:OpenClaw 自动校验 robots 协议与采集频率,规避违规采集风险
java·开发语言·c++·python·php·deepseek·openclaw
无名猿10 小时前
C++ 查找算法:std::find 与容器成员 find 的性能差距
c++·性能优化·stl·标准库
程序猿编码11 小时前
工业视觉多任务方案:可编程梯度信息视觉框架,训练评估 + C++ 部署完整实战
开发语言·c++·视觉·推理引擎
朝朝辞暮i12 小时前
C++ 第 21 课:struct —— 把一组相关数据打包在一起
开发语言·c++·算法
SHARK_pssm13 小时前
【C++——类和对象(下)】
开发语言·c++·经验分享·笔记
汉克老师13 小时前
GESP2026年9月认证C++二级( 第一部分选择题(1~7题)精讲
c++·gesp·小学生·学c++编程
沫璃染墨14 小时前
《从零入门Linux系统篇(五十四):线程篇·七——互斥锁底层原理:从原子交换到线程竞争与锁实现》
linux·运维·服务器·开发语言·c++·驱动开发·系统架构