那会儿我们写 C++。
很多人嘴上说 C++。
手里其实还是 C。
最多再加点 class。
构造函数也一样。
它看起来像“自动帮你把对象弄好”。
但你真写过几天。
就会发现它更像“手动装配流水线”。
而且没有复用。
更没有一键同步。
你改一处。
你得靠记忆去改十处。
你不改。
线上就替你改。
改成事故。
当年没有这招:构造函数只能各写各的
先说清一个词。
“构造函数”就是类里那个跟类同名的函数。 它负责把对象从“内存里一坨字节”变成“可用的东西”。
C++11 之前有个很别扭的限制。 构造函数不能直接调用另一个构造函数。
于是同一个类只要有多个入口。 你就容易写出多份几乎一样的初始化。
当年大家的第一反应很朴素:复制粘贴。 第二反应也很朴素:复制粘贴太丑。
于是抽个 init()。
这限制从哪儿来:C with classes 的影子
刚学 C++ 的时候,你很容易把它当成“带 class 的 C”。
这不怪你。
历史上它确实就是这么长出来的。
早期 C++(更早的时候大家还叫它 C with classes)。 目标很现实。 在不推翻 C 的前提下,加一点抽象能力。
构造函数是其中一块。 它的职责很纯粹:把对象弄成可用状态。
但“让构造函数再去调用另一个构造函数”这件事。 在当年的对象模型里不是白送的。
它牵扯到初始化的时机。 也牵扯到基类子对象先构造还是成员先构造。
一句话。
不好定规矩。
于是很长一段时间。 大家只能靠手艺活。
复制。
抽 init()。
写转发。
直到 C++11。 委员会才把这类“工程上天天写、天天出错”的模式。 变成语言能力。
不是凭空发明:它其实一直在“民间”存在
你可以把标准委员会想象成一个很慢的合并按钮。
大家在项目里早就这么写了。
只是写法五花八门。 也没人能保证每家都不踩坑。
委托构造之前。
你见过 init()。
见过工厂函数。
见过 Options。
它们本质上都在表达一句话。
同一个类。 初始化逻辑最好只有一个“主入口”。
继承构造之前也一样。 大家早就手写转发。 只是样板太多。
而在 Java/C# 这类语言里。
构造链(this(...)/super(...)、: this(...)/: base(...))更早就成了语法。
所以 C++11 做的事。 很像把“大家一直在做、也一直在摔跤”的经验。 收编成一个更难写错的语言规则。
为什么要等到 C++11:这事看着简单,其实卡在顺序
从外面看。 “构造函数调用构造函数”。 不就是一个函数调用吗。
但在 C++ 里。 构造不是普通函数调用。
对象会先构造基类那部分。 再构造成员。 最后才跑到函数体。
也就是说。 你在函数体里写的代码。 来得太晚。
这也是为什么你在 C++ 里常听到一句话。
“不要搞两段式初始化”。
两段式初始化的意思是: 先把对象弄出来。 再用一个函数把它“补齐”。
在 C 里这么干很常见。 在 C++ 里就很容易出现“半成品对象”。
半成品最可怕的地方。 不是丑。 是它偶尔还能跑。
成员、基类、初始化列表的顺序问题。 我们后面会用一个小例子把坑挖出来。
这也是为什么 Java/C# 把 this(...) / base(...) 限制成“第一句”。
因为它们也不想让初始化逻辑散成一坨。
C++ 只不过更极端一点。 它直接把这个“第一步”放进初始化列表。 你必须在真正开始构造之前,就把路线选好。
再加上 C++ 还要照顾各种历史包袱。 比如多个构造函数重载。 比如异常安全。 比如各种奇怪的成员类型。
所以这东西能拖到 C++11 才落地。 我一点都不意外。
线上啪一下:我加了一个成员,忘了改另一条构造路径
举个小得不能再小的例子。 你写了个小项目,做一个日志开关。
有时候写文件。 有时候写到控制台。
后来你加了一个 enabled。
结果忘了给其中一个构造函数赋值。
struct Logger {
bool enabled;
int level;
Logger() : enabled(true), level(1) {}
Logger(int lv) : level(lv) {}
};
Logger(int) 这条路径没有初始化 enabled。
它里面是啥,看运气。
运气差一点。 就会出现“我明明关了日志它还在写”的鬼故事。
这类 bug 最烦的地方在于,你本地复现不了。 因为你本地刚好内存是 0。
线上换台机器就变了。
“未初始化的变量”不是错。
它是一个定时炸弹。
当年的“曲线救国”:抽个 init(),然后踩进更深的坑
很多人会把重复收进 init()。
看起来很省事。
也算一种自救。
struct Log {
int level;
std::string path;
Log() { init(1, "app.log"); }
Log(int lv) { init(lv, "app.log"); }
void init(int lv, const std::string& p) {
level = lv;
path = p;
}
};
问题是:init() 是普通成员函数。
它运行时,对象已经“构造到一半”。
这里顺手把两个新名词拆开。
std::string 你可以先把它当成“C++ 自带的字符串”。
它帮你管内存。
比 char* 少踩很多坑。
const std::string& 你可以先当成“只读地借用一份字符串”。
一个常见目的,是避免拷贝。
你把本来该写在“初始化列表”里的东西。 挪进了函数体。
初始化列表就是 : level(lv), path(p) 这一段。
它发生在函数体 {} 之前。
更要命的是。
你一旦开始依赖 init()。
就会被迫面对一堆“初始化阶段”的硬规则。
有些成员必须在初始化列表里一次性定死。 你在函数体里补救不了。
于是你会发现自己又回到了复制粘贴。 而且更绕。
老坑之一:在构造函数体里写 Log(...),不是你想的那样
这是很多人从 C 过来后会本能写出来的代码。
struct S {
int x;
S(int v) : x(v) {}
S() {
S(1);
}
};
你以为 S() 会“调用 S(int)”。
但其实它只是构造了一个临时对象。
那个临时对象构造完。 下一行就被销毁了。
而当前这个对象的 x。
依然没有被初始化。
这就是当年大家特别渴望“构造函数能互相调用”的原因。 因为用手绕。 太容易绕出事故。
当年还有几条路:能用,但各有代价
老项目里你还会见到另外几种“当年人的智慧”。 它们都能减少构造函数数量。 但也都有副作用。
第一种。
默认参数。
struct Log {
int level;
std::string path;
Log(int lv = 1, const std::string& p = "app.log")
: level(lv), path(p) {}
};
它确实能少写好几个重载。 但当参数一多,你会开始分不清“这个 0 到底是哪个参数的 0”。
第二种。
工厂函数。
很多老代码会把这套写法叫成“命名构造函数”(Named Constructor)。 意思是:别再塞一堆重载了。 直接用函数名表达语义。
struct Log {
int level;
std::string path;
Log(int lv, const std::string& p) : level(lv), path(p) {}
static Log Default() { return Log(1, "app.log"); }
};
这种写法读起来很直观。 但它经常会把“怎么构造”散落到多个静态函数里。
你还是得靠自觉去维护一致性。
第三种。
Options(参数包)。
这个思路在很多语言/社区里也有个名字。 有人叫它“参数对象”(Parameter Object)。 也有人把它当成简化版的 builder。
struct LogOptions {
int level;
std::string path;
LogOptions() : level(1), path("app.log") {}
};
struct Log {
int level;
std::string path;
Log(const LogOptions& opt) : level(opt.level), path(opt.path) {}
};
它的优点是:参数有名字。 你不容易传错。
代价是:你要多维护一个结构体。 而且对于新手来说,心智负担会突然变重。
先把地基补齐:初始化列表到底在干嘛
很多新手卡在一句话上。
“构造函数里赋值,不也是初始化吗?”
不完全是。
你可以把它理解成两种动作。
先构造。
再赋值。
struct A {
std::string s;
A() : s("hi") {}
};
struct B {
std::string s;
B() { s = "hi"; }
};
A 是在初始化列表里直接把 s 构造成目标值。
B 是先把 s 用默认方式构造出来,再做一次赋值。
这在 int 这种类型上不明显。
在 std::string 这种“内部要管理资源”的类型上,差别就出来了。
更关键的是。
有些东西你根本没法“先构造,再赋值”。
比如 const 成员。
struct Config {
const int port;
Config(int p) : port(p) {}
};
const 的意思是“构造完就不许再改”。
所以它必须在初始化列表里一次性定下来。
再比如引用成员。
struct Holder {
int& ref;
Holder(int& x) : ref(x) {}
};
引用你可以当成“别名”。 它一出生就必须绑到某个变量上。 后面不能换绑。
还有一个坑。
成员初始化的顺序。
不是按初始化列表写的顺序。 是按成员在类里声明的顺序。
struct Weird {
int a;
int b;
Weird() : b(2), a(b) {}
};
这段代码很多人第一次看会以为 b 先是 2。
然后 a 等于 b。
但实际顺序是:先构造 a,再构造 b。
于是 a(b) 读到的是一个还没初始化的 b。
这类坑。 就是当年大家不太敢把“构造函数互相调用”放进语言的原因之一。
你一旦把规则做成语言特性。 就必须把这些顺序问题讲清楚。
委托构造:终于允许“构造函数调用构造函数”
C++11 给的解法其实很朴素。 允许一个构造函数把活交给另一个构造函数。
语法长这样。
struct Log {
int level;
std::string path;
Log(int lv, const std::string& p)
: level(lv), path(p) {}
Log() : Log(1, "app.log") {}
Log(int lv) : Log(lv, "app.log") {}
};
这就是“官方版本”的构造链。 不靠临时对象。 也不靠你自己记住要同步哪几份代码。
委托构造的规则也很“工程味”。
委托那一下。 必须写在初始化列表里。
而且当你写了委托。 初始化列表里就只能有它。
struct P {
int x;
P(int v) : x(v) {}
P() : P(1) {}
};
你可以把 P() 理解成“把入口统一到 P(int)”。
它自己不再碰初始化细节。
如果你又想委托。 又想顺手写个成员初始化。 编译器会拒绝你。
struct Q {
int x;
Q(int v) : x(v) {}
Q() : Q(1), x(2) {}
};
这段会编译失败。 因为语言要求:委托发生了,就别在这儿夹带私货。
你可以把它理解成一种强约束。
要么你自己负责初始化。 要么你把对象完全交给另一个构造函数。
不要两边都插手。 否则读者根本不知道“到底谁说了算”。
你可以把它和 Java/C# 的规则对照着看。
它们也要求 this(...) / base(...) 必须是第一步。
思路是一致的。 只允许你选一个“主入口”。 别让初始化变成一锅乱炖。
还有一个“别笑,真有人写过”的坑。
你可以把构造函数委托写成递归。
struct R {
R() : R() {}
};
它会自己调用自己。 直到栈爆掉。
所以委托构造的核心价值。 不是花活。
是把初始化的唯一入口钉死。
再来一个更“线上”的例子。 带点资源。
你写了个小服务。 启动的时候要打开一个配置文件。
你提供了两种构造方式。 一种用默认路径。 一种允许传路径。
结果你在两条路径里都写了一遍 fopen。
后来你改了行为。
只改了一条。
于是线上啪一下。 有的机器打开了两次。 有的机器没打开。
struct Conf {
FILE* fp;
Conf(const char* path) : fp(fopen(path, "r")) {}
Conf() : Conf("app.conf") {}
};
这段代码的重点不是 FILE*。
FILE* 你可以把它当成 C 时代的“文件句柄”。
重点是:你把“打开文件”集中到一个入口。
你少了一次忘改的机会。
这就是委托构造最朴素的价值。 把一件事只写一遍。
再给一个更像工程的例子。 带一点参数校验。
struct Port {
int v;
Port(int p) : v(p) {
if (v <= 0) v = 80;
}
Port() : Port(80) {}
};
你只在 Port(int) 里写规则。
默认构造和其他入口只是转发。
这比“每个构造函数都写一遍 if”靠谱。
横向看一眼:别的语言怎么做构造链
如果你写过一点 Java 或 C#。 你会觉得委托构造很眼熟。
Java 里你可以在构造函数里写 this(...) 或 super(...)。
而且它必须是第一句。
class D extends B {
D() { this(1); }
D(int x) { super(x); }
}
这个设计很直接。 同一个对象的初始化逻辑,总得有一个“主入口”。
C# 也类似。 只是写在签名上。
class D : B {
public D() : this(1) {}
public D(int x) : base(x) {}
}
你会发现。 它们解决的是同一个工程问题:别复制初始化逻辑。
但 C++ 的版本会更“前置”。 它发生在初始化列表阶段。
这不是为了炫技。
是因为 C++ 有 const、引用、基类子对象、以及各种资源管理。
很多东西必须在“构造那一刻”就决定好。
Log() 和 Log(int) 不再“自己初始化自己”。
它们只负责转发参数。
真正的初始化只在一个地方发生。 所以你改成员也只需要改一个构造函数。
那种“改了 A 忘了改 B”的事故就少一大半。 不变量也更好讲清楚。
不变量就是“这个对象活着的时候必须一直为真”的规则。 比如 level 不能为负。 比如 path 不能为空。
你把检查写在主构造函数里。 其他构造函数天然就跟着守规矩。
把规则集中起来。 比写十份规则更靠谱。
一句话的结论
重复的构造函数,本质上是在复制 bug。
继承构造:派生类别再当复读机
再说另一个当年特别折磨人的点:继承。 你可以把继承理解成“我在旧东西上加点料”。
旧东西叫基类。 新东西叫派生类。
当基类有好几个构造函数。 派生类想原样提供这些入口。
你以前只能手写转发。
struct Base {
Base(int);
Base(std::string);
};
struct Derived : Base {
Derived(int x) : Base(x) {}
Derived(std::string s) : Base(s) {}
};
这段代码看起来没毛病。
但一旦 Base 新增一个构造函数。
你就得记得来 Derived 再补一份。
漏一次就是编译不过。 更糟的是,你补错了这条构造函数“到底代表啥意思”。
于是 C++11 给了第二个“把样板赶出去”的能力。
struct Derived : Base {
using Base::Base;
};
using Base::Base; 的意思是:把基类的构造函数直接引进来。
不是复制粘贴。
是语言帮你生成那堆转发。
你少写的不是几行代码,而是少欠未来一堆“同步成本”。
继承构造还有个容易误会的点。
它不是“把基类构造函数复制进派生类源码”。
更像是。
把这些构造函数的名字引进派生类作用域。
也就是你看到的这个 using。
using 在 C++ 里本来就有“把别处的名字引进来”的意思。
你可能之前用它写过 using std::string;。
这里同一个味道。
只是引进的名字换成了构造函数。
当然。
它也不是万能的。
一旦派生类自己新增了成员。 你还是得亲手把它初始化好。
struct Base {
Base(int);
};
struct Derived : Base {
int extra;
using Base::Base;
Derived(int x, int e) : Base(x), extra(e) {}
};
你可以继承 Base(int)。
但你无法指望它顺便帮你把 extra 也管了。
派生类多出来的那点“料”。 还得你自己下锅。
还有一个很现实的规则。
如果派生类自己写了一个同签名的构造函数。 它会把继承来的那个“盖住”。
struct Base {
Base(int);
Base(double);
};
struct Derived : Base {
using Base::Base;
Derived(int) {}
};
这里 Derived(int) 会赢。
Base(int) 那个继承来的版本就不再参与重载解析。
但 Base(double) 还在。
所以 Derived(1.0) 依然能走继承构造。
继承构造的坑:有些构造函数会“继承不下来”
这一点很容易让新手懵。
你明明写了 using Base::Base;。
为什么编译器还是说你没有那个构造函数。
常见原因是:派生类里有成员,自己没法默认构造。
比如 const 成员。
struct Base {
Base(int);
};
struct Derived : Base {
const int extra;
using Base::Base;
};
extra 必须在初始化列表里初始化。
但继承来的 Base(int) 只负责构造 Base 那部分。
它不知道 extra 该是多少。
所以这些“继承来的构造函数”会被标记为不可用。
再比如引用成员。
struct Base {
Base(int);
};
struct Derived : Base {
int& ref;
using Base::Base;
};
引用必须一出生就绑定。 但继承来的构造函数没地方给它传绑定对象。
所以也会失败。
这时候你就得回到老办法。 自己写构造函数。 把 Base 的参数转发过去。
struct Derived : Base {
int& ref;
Derived(int x, int& r) : Base(x), ref(r) {}
};
继承构造省的是样板。 省不了你的业务语义。
一个老程序员的边界感
委托构造很香,但别把它写成迷宫。你最好能让读者一眼看出来哪个构造函数是真正“干活的入口”,其他只是路由。
继承构造也很香,但它适合那种“我只是加点料,不改含义”的派生类。如果派生类需要额外约束,你还是应该自己写构造函数,把意图说清楚。
最后留个小亮点。
横向对比小结:这两招到底解决了谁的痛
默认参数、工厂函数、Options。 都能让你少写几个构造函数。
但它们解决的是“代码量”。 不一定解决“同步成本”。
委托构造解决的是“同一类里多个入口的同步”。 继承构造解决的是“继承层次里那堆样板转发”。
它们看起来是语法糖。 但本质上是在给工程习惯开绿灯。
我一直觉得构造函数像工厂大门。
门口越多。
越要有一个总装线。
不然你迟早会在某个侧门口,漏装一颗螺丝。