`-1`、`void*` 和“指针 + 长度”有什么问题?用现代 C++ 类型写清接口契约

时间:2026/04/09

一个旧式接口经常把多种含义塞进基础类型:

1
2
3
int find_user(const char* name);       // -1 是没找到,还是发生错误?
void process(const int* data, int n); // data 能为空吗?n 能为负吗?
void* read_config(const char* key); // 返回的到底是什么类型?谁释放?

这些函数不一定立刻出错,但契约主要存在于注释和调用者记忆中。编译器只看到一个 int、两个彼此独立的参数和一个没有类型信息的地址,无法帮助检查“没有结果”“候选类型”“非拥有视图”等真实语义。

现代 C++ 的许多工具类型不是为了缩短几行代码,而是把这些状态写进类型系统。本文围绕一个配置值解析场景,说明 optionalvariantstring_viewspanany 和 C++23 expected 分别解决什么问题,以及视图类型为什么尤其容易产生悬空。


1. 类型怎样减少无效状态?

假设查询用户 ID 时,用 -1 表示没有结果:

1
int id = find_user("alice");

调用方必须额外知道负数不是合法 ID,还要记得检查。以后 ID 类型改为无符号整数,或者需要区分“不存在”和“数据库错误”,这套约定就会破裂。

更准确的接口应该让返回类型回答问题:

1
std::optional<UserId> find_user(std::string_view name);

现在调用方能从类型看到“结果可能不存在”,却仍不知道缺失原因。若失败原因属于业务所需信息,则应该使用错误通道,而不是继续把错误编码成数值。

可以把几种类型的核心语义概括为:

类型 表达的状态
optional<T> 有一个 T,或没有值
variant<A, B> 当前恰好是候选类型之一
expected<T, E> 成功得到 T,或失败得到 E
string_view 借用一段字符,不拥有它
span<T> 借用一段连续 T,同时携带长度
any 运行期保存一种编译期未限定的可复制类型

选类型的依据不是“哪个更新”,而是接口实际有哪些合法状态。

2. optional 何时比魔法值更准确?

std::optional<T> 适合“没有值是预期分支,而且调用方不需要失败原因”的场景:缓存未命中、可选配置、查找不到元素。

1
2
3
4
5
std::optional<int> find_port(std::string_view service);

if (auto port = find_port("http")) {
connect_to(*port);
}

常用访问方式的契约不同:

  • operator* / operator-> 要求已经确认有值;
  • value() 没有值时抛出 std::bad_optional_access
  • value_or(default) 产生一个值,没有时使用默认值。

不要用 value_or 隐藏本应处理的错误。默认端口适合可选配置,不适合把解析失败静默变成 0。

optional<bool> 往往需要特别小心,因为它有三种状态:无值、falsetrue。在条件表达式中,if (option) 检查的是“有值”,不是内部布尔值是否为真。

3. variant 怎样替代手写 tag 和 union?

配置值可能是整数、布尔或字符串。传统做法可能维护枚举标签,再用 union 或多个字段保存数据;标签与真实成员一旦不一致,就会错误访问对象。

1
using ConfigValue = std::variant<int, bool, std::string>;

variant 保证任意时刻只有一个候选值处于活动状态,并自动管理非平凡对象的构造与析构。通过 std::get_if<T> 可以按类型尝试访问,通过 std::visit 可以集中处理所有候选:

1
2
3
std::visit([](const auto& value) {
std::cout << value;
}, config_value);

当候选集合是封闭且已知的,variant 通常比基类层级更直接,也比 any 提供更早的类型检查。

如果所有候选操作都依赖巨大 if constexpr,也要检查模型是否真的共享同一个抽象。类型安全不会自动让混乱的业务分类变清楚。

4. string_view 为什么既高效又危险?

std::string_view 通常保存字符指针和长度,不分配、不复制,也不保证以 \0 结尾。它可以观察字符串字面量、std::string 或某个子串:

1
void parse(std::string_view text);

这很适合只在调用期间读取参数。但 view 不延长源字符串生命周期:

1
2
3
4
std::string_view dangling() {
std::string local = "temporary";
return local; // 返回后 local 销毁,view 悬空
}

类似地,修改源 string 导致重新分配后,原 view 也可能失效。需要保存文本时,返回或存储 std::string;只有所有者生命周期明确覆盖观察者时,才保存 string_view

string_view.data() 传给只接受 C 字符串的 API 也可能越界读取,因为 view 可以指向非空终止的子范围。API 需要 const char* C 字符串时,应确认终止条件或创建拥有的 std::string

5. span 比“裸指针 + 长度”多表达了什么?

std::span<T> 是连续对象序列的非拥有视图。它把地址和元素数量组合成一个参数:

1
2
void normalize(std::span<float> values);
void print(std::span<const float> values);

const float* data, std::size_t size 相比,调用者不容易把一个容器的指针和另一个容器的长度拼错,函数也能直接使用范围循环、size()subspan()

span<const T> 表示不能通过此 view 修改元素,不代表其他别名不会修改。span 也不拥有数据:源 vector 扩容、数组离开作用域或所有者销毁后,span 会悬空。

固定长度还可以进入类型:

1
void transform_matrix(std::span<const float, 16> matrix);

这让部分尺寸错误在构造 span 时暴露,但动态来源仍可能需要运行时验证。

6. 一个完整的配置值解析示例

下面的 C++20 程序接受一组借用的文本 token,解析为拥有数据的 variant。解析失败用 optional 表示;为保持示例聚焦,它没有携带详细错误,后文会说明怎样升级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <charconv>
#include <iostream>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <system_error>
#include <type_traits>
#include <variant>
#include <vector>

using ConfigValue = std::variant<int, bool, std::string>;

std::optional<ConfigValue> parse_value(std::string_view text) {
if (text == "true") {
return true;
}
if (text == "false") {
return false;
}

int number = 0;
const char* const begin = text.data();
const char* const end = begin + text.size();
const auto [position, error] = std::from_chars(begin, end, number);
if (error == std::errc{} && position == end) {
return number;
}

if (text.size() >= 2 && text.front() == '"' && text.back() == '"') {
return std::string(text.substr(1, text.size() - 2));
}
return std::nullopt;
}

void print_value(const ConfigValue& value) {
std::visit([](const auto& item) {
using T = std::decay_t<decltype(item)>;
if constexpr (std::is_same_v<T, bool>) {
std::cout << (item ? "true" : "false");
} else {
std::cout << item;
}
}, value);
}

void parse_all(std::span<const std::string_view> inputs) {
std::vector<ConfigValue> values;
values.reserve(inputs.size());

for (const std::string_view input : inputs) {
if (auto value = parse_value(input)) {
values.push_back(std::move(*value));
} else {
std::cout << "invalid: " << input << '\n';
}
}

for (const ConfigValue& value : values) {
print_value(value);
std::cout << '\n';
}
}

int main() {
const std::string_view inputs[]{"42", "true", "\"hello\"", "12px"};
parse_all(inputs);
}

编译运行:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -pedantic \
config_value.cpp -o config_value
./config_value

预期输出:

1
2
3
4
invalid: 12px
42
true
hello

parse_all 通过 span 借用输入序列,parse_value 再通过 string_view 借用单个 token。解析成功后,字符串候选被复制进 std::string,因此 values 不依赖输入文本继续存活。这个“入口借用、结果拥有”的边界非常适合解析接口。

from_chars 不要求输入以空字符结尾,并返回停止位置。只有错误码为空且 position == end 时才接受整数,所以 12px 不会被误解析为 12。

7. expected 什么时候比 optional 更合适?

上面的 nullopt 无法说明失败原因。调用方如果需要区分空输入、整数溢出和无效格式,C++23 的 std::expected<T, E> 更准确:

1
2
3
4
5
6
7
8
enum class ParseError {
empty,
invalid_format,
out_of_range,
};

std::expected<ConfigValue, ParseError>
parse_value(std::string_view text);

它表达“值或错误”,但不会自动记录调用栈,也不会替代异常的所有用途。选择可以基于失败性质:

情况 常见表达
没有值是普通且无需解释 optional<T>
可恢复失败是接口的常见分支 expected<T, E>
无法在当前层合理恢复、需要跨层传播 异常可能更合适
违反函数前置条件或程序不变量 断言、契约或明确终止策略

std::expected 是 C++23 标准库功能,编译器启用 -std=c++23 仍不保证所配标准库版本已经实现,使用前需要结合本机工具链确认。

8. any 什么时候才真正必要?

std::any 可以保存任意满足其要求的类型,并通过 std::any_cast<T> 在运行时取回:

1
2
3
4
std::any value = 42;
if (const int* number = std::any_cast<int>(&value)) {
std::cout << *number;
}

它适合候选类型无法在编译期封闭的边界,例如插件元数据、通用属性容器或框架扩展点。代价是:允许类型不再写在接口中,错误推迟到运行期,还可能发生动态分配。

如果候选只有 intboolstringvariant 更能表达完整集合,并让访问者的分支在编译期检查。不要用 any 把本应清晰建模的数据变成“万能盒子”。

9. variant 也可能进入特殊失败状态吗?

通常 variant 恰好持有一个候选类型。但在改变活动候选时,如果构造新值抛异常且无法保留原值,它可能进入 valueless_by_exception 状态。

1
2
3
if (value.valueless_by_exception()) {
// 按接口策略处理
}

对于 intboolstd::string 这样的常见组合,实际遇到概率较低,但严谨接口不能简单宣称它在任何情况下“永远有一个值”。访问错误候选的 std::get<T> 会抛 std::bad_variant_access,不确定类型时使用 get_ifvisit

10. 视图作为返回值什么时候安全?

返回 view 并非天然错误,关键是所有者是否明显活得更久:

1
2
3
4
5
6
7
class Dictionary {
public:
std::string_view word(std::size_t index) const;

private:
std::vector<std::string> words_;
};

调用方必须知道:Dictionary 销毁后 view 失效,words_ 修改或扩容也可能让字符地址失效。若很难在 API 使用范围内保证这些条件,返回拥有的 std::string 更安全。

同样,返回指向内部 vectorspan 会把容器重分配规则暴露给调用方。视图最稳妥的用途通常是短期函数参数和局部算法管道,而不是长期存储在其他对象中。

11. 工程中最容易踩哪些坑?

误区一:string_view 是更快的 string

它是非拥有视图,语义首先是借用,性能只是结果。需要保存内容时仍应使用 string

误区二:span 会做所有边界检查

span 携带长度并便于检查,但 operator[] 在 C++20/23 中不负责抛出越界异常。算法仍要满足索引前置条件。

误区三:optional 可以表示所有错误

它只区分有/无。调用方需要原因时,应提供错误类型,而不是另加全局错误码或日志猜测。

误区四:variant 的候选越多越灵活

巨大 variant 会让每个 visitor 承担许多无关分支。候选集合应该代表一个真正封闭的领域概念。

误区五:any_cast 失败返回空

指针形式 any_cast<T>(&value) 失败时返回空指针;值/引用形式类型不匹配时会抛出 std::bad_any_cast。接口要选择并处理对应形式。

12. 应该怎样选择这些类型?

可以从问题本身判断:

  1. 结果可能正常缺失且不需要原因:optional
  2. 值属于一组封闭候选:variant
  3. 失败原因是调用方逻辑的一部分:expected
  4. 函数只在调用期间读取字符:string_view
  5. 函数借用连续对象序列:span
  6. 扩展点的类型集合确实无法预先限定:谨慎使用 any

若需要共享所有权、异步生命周期或非连续范围,这几种工具并不能直接解决问题。类型应精确匹配契约,而不是用一张固定优先级清单替代设计。

13. 总结

开头的 -1void* 和“指针 + 长度”最大问题不是写法老,而是它们没有把接口中的合法状态和生命周期关系交给类型系统检查。

  1. optional 表达普通缺失,expected 表达值或明确错误;
  2. variant 适合封闭候选集合,any 只用于真正开放的动态类型边界;
  3. string_viewspan 让借用接口更清楚,却绝不延长源数据生命周期;
  4. 解析类接口适合“输入使用 view,输出返回拥有值”,避免结果悬空;
  5. 更准确的类型减少魔法值和非法组合,但仍要遵守访问、异常和失效规则。

设计新接口时,先用一句话写出返回值的所有合法状态,再选择能直接表达它们的标准类型。如果还需要靠注释解释“负数表示失败”“指针有时由调用方释放”,说明类型契约仍然不够准确。