const 正确性、API 设计与现代属性
const 正确性、API 设计与现代属性
时间:2026/05/08
关键词:
const、引用、值传递、explicit、[[nodiscard]]、noexcept、enum class、API 边界
核心目标:把“函数签名”写成清楚的契约,让调用者一眼知道所有权、可变性、失败方式和使用方式。
1. API 设计先看函数签名
函数签名不是只给编译器看的,它也是给人看的。
一个好的签名应该回答:
- 参数会不会被修改
- 参数会不会被保存
- 返回值是否必须检查
- 函数是否可能抛异常
- 构造是否允许隐式转换
- 调用者是否转移所有权
对比两个接口:
1 | void parse(char* data, int len); |
和:
1 | [[nodiscard]] Config parse_config(std::string_view text); |
第二个签名更清楚:
- 输入只是观察,不拥有
- 输入不会被修改
- 返回值值得检查
- 解析结果是一个明确对象
2. const 参数:表达“我不会改它”
读大对象时,优先用 const T&:
1 | struct User { |
如果写成值传递:
1 | void print_user(User user); |
会拷贝整个对象,通常没有必要。
如果参数很小,值传递更简单:
1 | void set_retry_count(int n); |
经验:
- 小标量:值传递
- 大对象只读:
const T& - 可空观察:
const T* - 连续数组观察:
std::span<const T> - 字符串观察:
std::string_view
3. const 成员函数:承诺不改对象状态
成员函数后面的 const 表示不会修改这个对象的可观察状态:
1 | class Cache { |
如果一个查询函数没写 const:
1 | std::size_t size(); |
那么 const Cache& 就不能调用它。
这会让 API 很难组合。
4. 什么时候用 mutable
mutable 用于“逻辑上不改变对象,但需要更新内部缓存”的场景。
1 | class Text { |
注意:
mutable不是绕过 const 的万能钥匙- 多线程读同一个对象时,mutable 缓存也需要同步
- 如果缓存逻辑复杂,优先考虑显式构建缓存对象
5. 字符串参数优先考虑 std::string_view
只读、不保存字符串时:
1 | void log_message(std::string_view msg) { |
它可以接收:
1 | log_message("hello"); |
但不要保存 string_view 指向临时对象:
1 | class Bad { |
如果对象要保存字符串,应该拥有它:
1 | class User { |
6. 连续数组参数优先考虑 std::span
传统接口:
1 | double average(const double* data, std::size_t n); |
现代写法:
1 |
|
可以接收:
1 | std::vector<double> v{1.0, 2.0, 3.0}; |
span 只观察,不拥有。
不要让 span 活得比底层数组更久。
7. 值传递 + move:接收要保存的对象
如果函数要把参数保存到成员里,经常可以用值传递:
1 | class Person { |
调用者传左值时会拷贝一次:
1 | std::string n = "Alice"; |
调用者传右值时通常很高效:
1 | Person p("Alice"); |
这比同时写 const std::string& 和 std::string&& 两套重载更简单。
8. explicit:阻止意外隐式转换
单参数构造函数默认可能触发隐式转换:
1 | class Port { |
很多时候这不是你想要的。
更推荐:
1 | class Port { |
经验:
除非你明确希望它能隐式转换,否则单参数构造函数优先写
explicit。
9. [[nodiscard]]:返回值不能随手丢
错误处理或资源构造结果经常不能忽略:
1 | enum class Error { |
调用者如果丢掉返回值,编译器会提醒:
1 | save_config("app.toml"); // 可能警告 |
更清晰的写法:
1 | if (auto err = save_config("app.toml"); err != Error::none) { |
也可以标记类型:
1 | struct [[nodiscard]] Result { |
适合 [[nodiscard]] 的返回值:
- 错误码
std::optionalstd::expected- 资源句柄
- 需要调用者继续使用的 builder 结果
10. noexcept:承诺不抛异常
noexcept 不只是优化提示,也是契约。
1 |
|
移动构造如果是 noexcept,容器扩容时更愿意移动元素而不是拷贝元素。
不要乱写:
1 | void f() noexcept { |
经验:
- 析构函数默认应不抛
- 移动构造/移动赋值能保证不抛时写
noexcept - 低层工具函数如果不抛,可以写
noexcept - 不能保证时不要硬写
11. enum class:避免枚举污染和隐式转换
老式 enum:
1 | enum Color { |
更推荐:
1 | enum class Color { |
如果要指定底层类型:
1 | enum class HttpStatus : int { |
enum class 的好处:
- 名称不污染外层作用域
- 不会随便隐式转整数
- API 可读性更强
12. 现代属性的几个实用场景
12.1 [[maybe_unused]]
用于故意未使用的变量或参数:
1 | void on_debug_event([[maybe_unused]] int code) { |
12.2 [[deprecated]]
给旧接口留迁移提示:
1 | [[deprecated("use parse_config_v2 instead")]] |
12.3 [[fallthrough]]
明确 switch 穿透是有意的:
1 | switch (level) { |
这些属性不是装饰,它们让代码意图对编译器和读者都更清楚。
13. 返回值设计:值、引用、指针怎么选
13.1 返回值
最常见、最安全:
1 | std::vector<int> make_ids(); |
现代 C++ 有移动语义和返回值优化,不要过早改成输出参数。
13.2 返回引用
表示返回对象内部已有内容:
1 | class User { |
注意调用者不能让引用超过对象生命周期。
13.3 返回指针
适合表达“可能没有”且不转移所有权:
1 | const User* find_user(int id) const; |
如果没有值,也可以用:
1 | std::optional<User> find_user(int id) const; |
如果对象很大、不想复制,并且不拥有,就用指针或引用包装语义说清楚。
14. 一个完整的小 API 示例
1 |
|
这个例子里:
- 构造函数
explicit - 只读查询是
const - 可能没有结果时返回指针
- 必须关注的查询结果加
[[nodiscard]] - 保存参数时用值传递 + move
- 批量只读输入用
span<const User>
15. 常见误区
15.1 所有参数都写 const T&
小整数、枚举、轻量句柄直接值传递更好。
15.2 到处返回 const T&
如果返回的是临时对象或局部变量引用,会立刻悬空。
现代 C++ 返回值通常很便宜,别怕返回对象。
15.3 string_view 当成字符串成员保存
除非你非常清楚底层字符串生命周期,否则成员里保存 std::string。
15.4 所有函数都加 noexcept
noexcept 是承诺,不是祝福。
承诺错了会直接终止程序。
15.5 不写 explicit
隐式转换引起的问题很隐蔽。
构造函数默认倾向 explicit 是很好的工程习惯。
16. 一页总结
现代 C++ API 设计最常用的几条规则:
- 只读大对象用
const T& - 字符串观察用
std::string_view - 连续数组观察用
std::span - 要保存的对象可以值传递再 move
- 查询成员函数尽量写
const - 单参数构造函数优先
explicit - 重要返回值加
[[nodiscard]] - 能保证不抛时才写
noexcept
一句话:
好 API 的核心是把所有权、可变性和失败语义写在签名里,而不是藏在文档里。