那是很多年前。
办公室的灯。
经常亮到半夜。
我们写的 C++。
其实就是 C。
外加一点 class。
那时候最响的一句话是。
“能跑就行。”
后来我才知道。
有些东西。
一旦“能跑”。
就会跑去你没想到的地方。
那会儿的 C++,还没那么“讲规矩”
我们那会儿写 C++。 经常就是“C with classes”。
struct 里塞点函数。
再加个构造函数。
能跑起来就算交差。
很多你今天依赖的护栏都没有。 编译器也不会替你多想。 它只负责把你写的东西翻译成机器能跑的东西。
所以大家写得很随意。 也很自信。 直到某天你发现:最吓人的不是写错了,是写错了还能编译。
一个看起来很正常的愿望
项目里总有些“句柄”类型。 你可以把它理解成。 “一个小对象,代表一个外部资源”。
比如一个连接。 比如一个文件。 比如一把锁。
你最想要的体验就是像指针一样。
需要判断的时候。
塞进 if 里问一句。
if (conn) {
// 连接是好的
}
当年这需求太朴素了。
朴素到大家第一反应就是。
那我就写个 operator bool() 呗。
在当年的语境里。 这甚至算一种“聪明”。
线上啪一下:我只是少打了三个字母
故事从一个“小项目”开始。
真的是小项目。
一个简单的聊天室。
每个连接有个 Conn。
你只想判断它是不是还活着。
struct Conn {
int id;
Conn(int id) : id(id) {}
operator bool() const { return id != 0; }
};
看起来没毛病。
然后有一天。
线上报警。
“怎么所有人的消息都串了。”
我打开代码。
看到这行。
std::vector<int> slot(100);
slot[conn] = user_id;
你可能会说。
这不就写错了嘛。
应该是 slot[conn.id]。
问题是。
它居然能编译。
而且单机压测的时候。
看起来也“能跑”。
因为连接一多。
所有人都被塞进了 slot[1]。
前一个人的数据。
被后一个人覆盖。
聊天室就变成了。
“大家轮流当一个人”。
先把几个词掰开
你可能会皱眉,operator bool() 到底是个啥。
它叫“类型转换运算符”,你可以把它当成一种成员函数,只不过名字有点怪。
长这样:operator T()。
意思是:必要的时候,我可以把自己当成 T。
operator bool() 是最常见的一种,因为大家都想写 if (obj)。
而且你刚学完 C,脑子里对“真/假”的印象很简单:不等于 0 就算真,等于 0 就算假。
所以很多人会下意识觉得,“能进 if”,也差不多等于“能当数字”。 坑就埋在这里。
C 时代的习惯:0 就是假
你回忆一下 C。 当年判断“有没有”,经常就是看一个整数或指针。
FILE* f = fopen("a.txt", "r");
if (f) {
}
f 不是 bool,它是一个指针。
但在 if 里,大家默认“非 0 就是真”。
这套习惯后来被很多 C++ 代码带进了类。
大家就开始写 operator bool()。
区别在于。
在 C 里你写 if (f),你很清楚自己在用指针。
在 C++ 里你写 if (conn),你其实是在请求编译器帮你“变个类型”。
这个请求一旦开了口子,就很难只让它用在 if。
这锅不是 vector 的,是“隐式转换”的
你以为你给 Conn 加的是“能不能放进 if”。
编译器看到的是:这个类型现在有了一条“随时能变成别的类型”的路。
所谓“隐式转换”,就是你没写转换代码。 但编译器替你补上了。 它的出发点挺好:让表达式“能对上类型”。
但 operator bool() 特别容易越界。
在这里,它先把 Conn 变成 bool。
这一步你肉眼看不见。
然后 bool 又会继续变成 int。
最后拿这个 int 去当数组下标。
因为在 C++ 里。
bool 算是“整数家族”的一员。
你不一定需要记标准条文。 你只要知道。 它能参与整型提升。
需要 int 的地方。
它就会被“提升”成 int。
true 变成 1。
false 变成 0。
Conn conn{42};
int x = conn; // 先变成 true,再变成 1
你写的是“能不能用在判断里”。 它给你的是“到处都能当数字用”。
再看几个“能编译但跑歪”的例子
这个坑最烦的一点是。 它不止会影响下标。
它会悄悄混进别的表达式里。 你还以为自己写的是业务逻辑。
比如算术。
Conn c{42};
int y = c + 7; // y 是 8
你以为你在算连接 id。
实际上你在算 true + 7。
再比如你想“按连接 id 生成点东西”。
Conn c{42};
std::string s(c, 'x');
你以为你在生成 42 个字符。 实际只生成了 1 个。
因为 c 先变成了 true。
再变成了 1。
再比如 switch。
Conn c{42};
switch (c) {
case 0: break;
case 1: break;
}
看起来像在写业务分支。
其实你只是在分 false/true。
再比如函数重载。
void log(int);
void log(bool);
Conn c{42};
log(c);
你可能本来想走 int 版本。
结果它先变成了 bool。
就跑去调用了另一个函数。
还有一种很阴险。
Conn c{42};
if (c == 2) {
}
它会编译。 但这个判断几乎永远是错的。
因为 c 先变成 true。
再变成 1。
然后你在比较 1 == 2。
还有一种特别像“没毛病”的。
Conn c{42};
std::vector<int> v(c);
你以为你传了个连接对象。
编译器觉得你想要一个 size。
于是给你造了一个大小为 1 的 vector。
横向对比:为什么有的语言干脆不让你这么写
你如果写过一点 Java/C#。 可能会发现。
它们的 if 里。
基本只接受真正的 bool。
你不能拿一个对象进去。 也不能拿一个整数进去。
这当然少了点“灵活”。 但换来的就是。 少了很多“能编译但跑歪”。
C/C++ 这边历史包袱重。 又太在乎兼容性。 所以才会走到。 先靠 safe-bool 自救。 再靠 C++11 给出官方开关。
当年大家怎么自救:safe-bool
你可能会问。 那当年大家就这么认栽吗。
不。
而且这招不是民间瞎折腾。 标准库自己也吃过这个亏。
最典型的就是 iostream。 大家都想写。
if (cin) { ... }
但没人想要。
int x = cin;
所以很长一段时间里。
很多实现会用一种“很奇怪的返回类型”。
比如直接返回一个指针类型(常见是 void* 这一类)。
大概长这样。
struct Conn {
int id;
operator const void*() const {
return id ? this : 0;
}
};
它的味道很像 C。 非 0 就当真。
好处是。
它不太可能再被当成 int 去做算术。
坏处是。
你又引入了“指针”这套麻烦。
而且 void* 也会参与各种重载匹配。
一不小心还是会拐弯。
更麻烦的是。
这段代码会把读者往“指针/转换/生命周期”那堆坑里带。
你明明只是想写 if (conn)。
目的就一个。
能在 if 里用。
但别拿去当数字。
Boost 的 smart pointer 也流行过类似写法。
因为 if (p) 太常见。
但 p + 1 这种灾难也太常见。
老一辈不是没挣扎过。
他们发现问题不在 if。
问题在“这个 bool 还能继续被当成 int”。
于是就有了 safe-bool idiom。
思路很朴实:我不返回真正的 bool。
我返回一个更不像数字的东西。
最常见的做法,是返回“成员函数指针”。 你可以把它理解成。 “指向某个类成员函数的指针”。
它有“有没有”的概念。 但很难拿去做算术。
struct Conn {
int id;
Conn(int id) : id(id) {}
void ok() const {}
typedef void (Conn::*safe_bool)() const;
operator safe_bool() const {
return id != 0 ? &Conn::ok : 0;
}
};
能用。
但读起来像暗号。
你写给编译器看。 顺便把同事也劝退了。
你可以把 safe-bool 当成一个时代的妥协。
那会儿标准里没有一个“官方按钮”。
大家只能用更奇怪的类型。
去堵住 bool -> int 这条后门。
void* 那条路。
更像是“借 C 的老习惯”。
能用,但会把你拖回指针世界。
成员函数指针那条路。 更像是“我宁愿怪一点,也别像数字”。 安全一些,但读起来像密码。
C++11:终于有了正经招式
后来 C++11 给了一个很实在的开关:explicit。
你可以告诉编译器。
我允许你在“需要 bool 的地方”。
临时把我当成 bool。
但别把我拿去做别的。
你可以把它理解成。 标准把 safe-bool 那套暗号。 翻译成了人话。
struct Conn {
int id;
explicit operator bool() const noexcept {
return id != 0;
}
};
这里的 explicit,就是“需要我点头”。
你放进 if。
可以。
Conn c{42};
if (c) {
}
你想把它当整数。
不行。
你当年那种写法。 现在也不行。
std::vector<int> slot(100);
slot[c] = 1;
编译器会直接拦住你。
让你回去写 slot[c.id]。
Conn c{42};
int x = c; // 编译器会拦住你
这类 if (c) 这种位置。
有人管它叫“需要 bool 的语境”。
别被词吓到。 你就记住。 就是条件判断那一撮地方。
这里有个很关键的点。
explicit 的意思是。
一般情况下。
你得“明确写出来”才允许转换。
但标准又专门开了一个口子。
让它在条件判断里用起来不别扭。
这就是为什么 explicit operator bool() 还能写 if (c)。
你把它当成一条很朴素的规则就行。 需要“真/假”的地方,编译器可以帮你变一次。
不是需要“真/假”的地方。 你就得自己写清楚。
编译器允许你“临时当一下 bool”。 但不允许你“顺手变成别的”。
比如这些。
Conn c{42};
if (c) {}
while (c) {}
bool b = !c;
bool ok = c && true;
它们都需要一个“真/假”。 所以编译器允许你临时变一次。
但下面这些就不行。
Conn c{42};
int n = c;
auto m = c + 1;
你想把它当数字。 编译器就会说。 不行。
你想自己说清楚。 那就用显式转换。
Conn c{42};
bool ok = static_cast<bool>(c);
这句话的潜台词是。 我知道我在做转换。 我愿意承担这个转换的语义。
你现在看到的很多标准库类型。 其实都走了这条路。
比如智能指针。
std::shared_ptr<int> p;
if (p) {
}
它能进 if。
但它不会悄悄变成 int。
这不是巧合。 这是 C++11 之后大家终于统一的写法。
连 iostream 也是。
从“奇怪的返回类型”,回到了 explicit operator bool()。
你可能见过 explicit,只是没把它们串起来
你学构造函数的时候。
大概率已经见过 explicit。
它的味道一直都很一致。
有些转换。 你不想让编译器“顺手”帮你做。
比如这个。
struct Meter {
int v;
explicit Meter(int x) : v(x) {}
};
void f(Meter);
f(3); // 没写清楚,就别让它过
这叫“别让别人随便进来”。
explicit operator bool() 反过来。
是“别让自己随便出去”。
洞见也在这。
隐式转换这东西。 本质是在类型边界上开洞。
洞开得越多。 你越依赖“大家都不犯错”。 而不是依赖编译器。
顺手说一句 noexcept。
它的意思是:这个函数保证不抛异常。
不是必须。
但写出来,读代码的人心里更稳。
不止 bool:你也可以把“转换成本”写进类型
有时候你确实能转换成别的类型。
比如一个 UserId。
你能把它转成字符串。
但你不希望它在拼接、日志、重载里。
随便被编译器拉平。
“重载”你先别紧张。 就是同名函数,参数不一样那套。 编译器一多想,就容易选错。
struct UserId {
int v;
explicit operator std::string() const {
return std::to_string(v);
}
};
你真想要 std::string。
就明确写出来。
UserId id{42};
auto s = static_cast<std::string>(id);
static_cast 你可以理解成。
“我亲口说的”。
我就是要把它转成这个类型。 别帮我猜。
读代码的人会更放心。 因为他知道,这次转换是你自己决定的。 不是编译器“热心帮忙”。
一句话收尾
把 explicit 用起来。
你其实是在把“线上事故”。
换成“编译器红字”。
这笔账。
很划算。