我最怕那种返回值。
它不报错。
但它也没告诉你。
“这次其实没拿到结果”。
你接到的是一个看起来很正常的 int。
或者一个看起来很正常的指针。
你就照着往下写。
一直写到上线。
然后某个周五晚上。
啪一下。
当年:我们用“约定”表达“没有”
那时候 C / C with classes 的世界很朴素。
函数返回一个值。
你就默认它是“结果”。
至于“没有结果”。
靠约定。
指针返回 NULL。
整数返回 -1。
字符串返回空。
这套东西能跑。
也省事。
代价也很直白。
“有没有”这件事,不在类型里。
它藏在你脑子里。
线上复盘:-1 其实是合法值,我拿它当“没找到”
我最早写过一个小工具。
从配置里读端口。
没有配置就用默认。
接口当年长这样。
int read_port();
我写调用点的时候也很自然。
int port = read_port();
if (port == -1) port = 8080;
当时我觉得自己挺聪明。
因为我“记得住”。
后来接了一个特殊模式。
它真会用 -1 表示“随机端口”。
我这段代码就把特殊模式吞了。
服务每次都跑在 8080。
监控一看。
还挺稳定。
稳定得离谱。
那次之后我才承认。
把语义放在脑子里。
就是在和走神打赌。
旧办法 1:再加一个“哨兵值”,然后祈祷它永远不合法
你可能会说。
那我别用 -1。
我用 0。
我用 65535。
我用“一个不可能出现的值”。
这就是第一轮补丁。
它的问题也很直白。
你得证明它“不可能出现”。
而工程里最不值钱的就是“证明”。
需求一变。
不可能就变可能。
旧办法 2:bool + out 参数,看起来靠谱,但可读性很差
于是我们会走向第二种写法。
让函数告诉你。
“我到底有没有读到”。
bool read_port(int& out_port);
调用点一般会写成这样。
int port = 0;
if (!read_port(port)) {
port = 8080;
}
这次语义确实更准确。
但是我每次看到这种接口。
都要停一下。
到底哪个是输入。
哪个是输出。
而且它还有个更现实的坑。
你很容易把 read_port(port); 写完就走。
不检查返回值。
编译器也没法帮你。
我真正想要的性质:把“有没有”变成返回值的一部分
我想要的其实很简单。
函数返回一个东西。
这个东西自己携带两个事实。
“有值”。
或者“没有值”。
然后调用点在拿值之前。
必须先面对这个事实。
这不是为了优雅。
是为了让错误更难藏起来。
它怎么来的:optional 不是 C++17 才突然想出来的
“可能有值,也可能没值”这种类型。
更早就在别的语言里出现过。
比如 ML / Haskell 的 Maybe。
比如 Rust 的 Option。
在 C++ 圈子里。
很多人早就用 Boost.Optional 顶着用。
C++17 只是把这件事。
变成标准库的一部分。
C++17:std::optional,把“可能没有”写进类型里
你先把它当成一个“带开关的盒子”。
盒子里可能装着 T。
也可能是空的。
#include <optional>
std::optional<int> read_port();
调用点会被迫写出“分支”。
auto port = read_port();
if (port) {
use_port(*port);
}
这里最值钱的不是 *port。
是那句 if (port)。
它把“你记得检查吗”变成了“你必须写出来”。
先把手感摸出来:if (opt) / has_value() / * / ->
如果你刚从 C 过来。
你可以先把 std::optional<T> 当成“一个可能为空的变量”。
空不空这件事,就是在问:它到底有没有值。
std::optional<int> x;
bool empty1 = !x;
bool empty2 = !x.has_value();
!x 用的是 optional 的布尔判断。
has_value() 只是把同一件事写得更明白。
当它“有值”的时候。
你才能用 *x 把值拿出来。
std::optional<int> x = 42;
int v = *x;
如果 T 本身是个对象。
你经常会用到 ->。
std::optional<std::string> s = "hi";
std::size_t n = s->size();
这句能跑的前提是:s 里面真有值。
旧问题再走一遍:默认值怎么办
当年我写 if (port == -1) port = 8080;。
其实是在做一件事。
“没有配置就用默认”。
optional 里这件事有个专门的名字。
value_or。
int port = read_port().value_or(8080);
你可以把它理解成。
有值就用它。
没值就用备胎。
这里有个很容易踩的点。
value_or 的参数会先求值。
int expensive();
int v = read_port().value_or(expensive());
所以哪怕 read_port() 里有值。
expensive() 也已经执行了。
真想要惰性。
就得把分支写出来。
auto p = read_port();
int v = p ? *p : expensive();
没有值要怎么写:nullopt
当你想明确表达“就是没有”。
不要返回一个“看起来正常”的哨兵。
直接返回 std::nullopt。
std::optional<int> read_port() {
return std::nullopt;
}
这里的信号很干净。
也很诚实。
如果你手里本来有值。
想把它清空。
也有一种直白的动作。
std::optional<int> x = 1;
x.reset();
如果你想在盒子里“原地构造一个对象”。
不想先造一个临时对象再搬进去。
可以用 emplace。
std::optional<std::string> s;
s.emplace(10, 'a');
继续踩坑:value() 不是免费午餐
optional 也允许你“直接拿值”。
但代价也很直接。
int port = read_port().value();
如果里面没值。
它会抛异常(std::bad_optional_access)。
它和 *opt 不一样。
value() 会抛异常。
*opt 在没值时是未定义行为。
std::optional<int> x;
int v = *x;
所以我自己的习惯是。
要么写 if (x) { ... }。
要么写 value() 并承认它是异常路径。
这句代码等于在说:
“没有值属于异常路径”。
如果你真这么想。
就用它。
如果你只是图省事。
那你会在生产里见到它。
optional 不是指针:它不分配堆,也不替你管理生命周期
很多新人第一次见 optional。
会把它想成“高级指针”。
其实不是。
它通常是“就地存放”。
也就是。
optional<T> 里面大概率真的有一块 T 的存储空间。
再加一个标志位。
所以它也带来一个很现实的代价。
T 很大时。
optional<T> 就会很大。
它不是零成本抽象。
它是“把成本挪到你能看见的地方”。
你以为 optional 能救的坑:悬空引用/悬空指针
我见过最常见的误会是。
“我都 optional 了,应该安全点吧?”
不。
optional 不拥有你塞进去的指针。
它也不会延长对象生命周期。
std::optional<const char*> bad() {
std::string s = "hi";
return s.c_str();
}
这段依然会悬空。
因为悬空的根因不是“有没有值”。
是“指向的东西已经死了”。
optional 没法替你把死人复活。
另一个常见误会:optional 不能装引用
有时候你不想拷贝一个大对象。
你只想“要么给我一个引用,要么没有”。
很多人会下意识写。
std::optional<std::string&> find_name();
这在标准库里是不行的。
optional 只能装对象类型。
真要表达“可选引用”。
通常用 std::reference_wrapper。
#include <functional>
std::optional<std::reference_wrapper<std::string>> find_name();
你拿值的时候会长这样。
auto name = find_name();
if (name) {
use(name->get());
}
当我需要的不只是“没有”:错误到底是什么
optional 只回答一个问题。
“有没有值”。
它不回答。
“为什么没有”。
当“没有”是正常分支(比如查缓存没命中)。
optional 很合适。
当“没有”其实是错误(比如文件读失败)。
我更倾向于把错误原因也带回去。
在 C++17 里你常见的做法是:
返回 std::variant<T, Error>。
或者继续用错误码/异常。
到 C++23 则有 std::expected<T, E> 这种更直白的工具。
optional 不该被硬拗成“错误处理框架”。
如果你想看一个“最小对比”。
optional 只能告诉你“有没有”。
variant 至少能把原因也塞进去。
using PortOrErr = std::variant<int, const char*>;
PortOrErr read_port2();
调用点就可以在“没值”之外。
再多知道一点。
PortOrErr r = read_port2();
if (auto p = std::get_if<int>(&r)) {
use_port(*p);
} else {
const char* err = std::get<const char*>(r);
}
这段代码的重点是。
你不仅能区分“有没有”。
还能拿到“为什么没有”。
关键结论
optional 的价值不在于少写几行 if。
在于它把“可能没有”从约定。
变成了类型系统的一部分。
小结:我现在怎么选
当“没有结果”是业务里正常会发生的事。
我就让返回类型直接说出来。
std::optional<T>。
当“没有结果”意味着错误。
我就让接口把错误也说出来。
异常。
错误码。
或者 expected/variant。
最后用一句老工程师的直觉收刀。
把语义放在脑子里。
就是在和走神打赌。