2.1 如何设计一个函数接口
我们已经会把一段代码装进函数,但一个函数是否容易使用,往往不取决于函数体写得多巧妙,而取决于调用者能否从声明中看出:它需要什么、会修改什么、可能失败吗、结果由谁负责。
bool find_student(int id, Student *result);只看这个声明,就会冒出不少问题:
result可以为空吗?- 函数会不会保存这个指针?
- 找不到学生时,
result仍然有效吗? - 返回的
bool表示是否找到,还是表示程序出错?
函数声明是调用者与实现者之间的契约。好的接口会尽量让类型替我们回答这些问题。
参数与数据流动
最常见的参数可以先按「输入
小对象按值传递
int、枚举、指针等容易复制的小对象,通常直接按值传入:
bool is_passing(int score, int passing_score) {
return score >= passing_score;
}函数得到参数的一份副本。修改副本不会影响调用者,也不存在引用悬空问题。没有必要为了更快而把 int 改成 const int&,通常这只会增加一次间接访问,并不会更快。
避免微优化
大家都希望程序尽可能高效地运行,但代码的整体清晰度和可维护性同样重要。
微优化是指对代码进行微小的改动以试图提升性能,但往往以牺牲可读性、可维护性甚至引入新瓶颈为代价。之所以说「微」优化,是因为这样的改动通常只节省极少量的时间或内存占用,或者甚至对性能毫无提升、乃至起到反效果。
大部分程序的性能瓶颈都不在这些小地方。例如输入输出、网络请求等昂贵操作的成本通常比微优化省下的高出几个量级。合理的做法是先用性能分析工具找出真实瓶颈,避免盲目优化。
基于公认的最佳实践编写代码,保持代码清晰、易维护。
较大的只读对象使用 const 引用
如果函数只读取一个复制成本较高的对象,可以使用 const T&:
#include <iostream>
#include <string>
void print_name(const std::string &name) { std::cout << name << '\n'; }这里的引用表示借用,const 表示函数不会通过这个接口修改字符串。引用必须绑定到对象,自然而然地避免了指针参数收到空指针 nullptr 等问题;const 引用也可以绑定到临时对象,所以这个对象不一定有名字。
修改调用者对象时使用普通引用
普通引用参数明确表示函数可能修改对象:
void add_bonus(Student &student, int bonus) { student.score += bonus; }调用处能看到传入的是一个对象,却不能像某些语言那样从语法上直接看出它会被修改。因此函数名、参数名和文档仍然要写清楚。
如果函数只为产生一个新值,返回结果通常比修改输出参数更自然:
int with_bonus(int score, int bonus) { return score + bonus; }可空的借用可以使用指针
引用表达「一定有对象
void print_student(const Student *student) {
if (student == nullptr) {
std::cout << "No student selected\n";
return;
}
std::cout << student->name << '\n';
}这里的裸指针只观察对象,不取得所有权,也不应在函数中 delete。如果没有「空」这一合法状态,应优先使用引用,让无效调用无法轻易发生。
IMPORTANT
T* 与 T& 参数通常只借用对象。把所有权交给函数时,应使用值、资源管理类型或智能指针明确表达,不能要求调用者猜测谁负责释放。
连续数据不要只传首地址
传统接口常把数组拆成指针和长度:
double average(const int *scores, std::size_t count);两个参数必须始终匹配,但类型系统不知道它们之间的关系。第一章介绍的 std::span 能把它们合为一个范围:
#include <span>
double average(std::span<const int> scores);类似地,只读字符序列常用 std::string_view 借用。它们都不延长底层对象的生命周期。
返回值优先
过去有人担心返回 std::string、std::vector 等对象会产生昂贵复制,于是偏爱输出参数:
void make_report(const Student &student, std::string &output);现代 C++ 通常直接返回结果:
#include <string>
std::string make_report(const Student &student) {
return student.name + ": " + std::to_string(student.score);
}编译器可以进行复制消除;不能消除时,也通常可以使用移动语义转移结果中的资源。因此不要为了「避免复制」而写出难以理解的接口:
std::string make_name() {
std::string result{"Alice"};
return result; // 正常返回,让编译器进行复制消除或移动
}返回多个相关结果时,可以定义一个有名字的结构体:
struct Statistics {
double average{};
int minimum{};
int maximum{};
};
Statistics calculate_statistics(std::span<const int> scores);这比让调用者准备三个可修改引用更容易读,也为以后增加字段留下了位置。而且调用者可以通过 auto 方便地承接返回值。
用类型表达缺失与失败
表达「可能没有」
魔法值会把正常数据和特殊状态混在一起:
int find_score(int id); // 返回 -1 表示没找到?如果 -1 不是正常结果,勉强可以约定它表示失败,但调用者容易忘记检查。C++17 的 std::optional<T> 明确表示「可能包含一个 T,也可能为空
#include <optional>
#include <span>
std::optional<Student> find_student(std::span<const Student> students,
int id) {
for (const Student &student : students) {
if (student.id == id) {
return student;
}
}
return std::nullopt;
}调用者必须面对这个额外状态:
if (const auto student = find_student(students, 1001)) {
std::cout << student->name << '\n';
} else {
std::cout << "Student not found\n";
}这里在 if 内定义 optional 变量,不仅方便判断,还能自然地将 student 的作用域限制在整个 if/else 中,是 optional 很典型的现代 C++ 用法。
注意 optional 本身不是值,而是类似指针。比如这里返回的 student,若其有效,*student 指向的是 Student 对象,使用 student->name 访问属性。
optional 适合「没有结果本身很正常,而且不必解释原因」的情况,例如查找没有命中。它不适合承载详细错误信息。
语言差异之类型窄化
在 TypeScript 中T | undefined 或 T | null 表示。考虑下面的代码:
interface Student {
id: number;
name: string;
score: number;
}
function foo(): Student | null {
return null;
}
const student = foo();
console.log(student.name);
// “student” may be null
if (student !== null) {
console.log(student.name); // 无报错
}TypeScript 的类型系统会在直接访问 student.name 时报错,但使用 if (student !== null) 判断之后,类型系统就会自动放行。
但是在 C++ 中:
#include <iostream>
#include <optional>
struct Student {
int id{};
std::string name{};
int score{};
};
std::optional<Student> foo() { return std::nullopt; }
int main() {
const auto student = foo();
std::cout << student->name << '\n';
}如果不经判断直接访问 student->name,编译器并不会报错,甚至连警告都没有,直接放行。使用 GCC 在运行时,GCC 发生错误:
C:/msys64/ucrt64/include/c++/16.2.0/optional:1230: constexpr const _Tp* std::optional<_Tp>::operator->() const [with _Tp = Student]: Assertion 'this->_M_is_engaged()' failed.值得一提的是,这是一个 UB,因此不同平台行为可能不同,不一定是报错。
那为什么 C++ 没有在编译期报错或警告呢?
在 TypeScript 中,student 的类型是 Student | null,但在判断 if (student !== null) 后,类型系统会自动将 student 的类型窄化为 Student,因此在 if 内访问 student.name 不会报错。
在 C++ 中,student 的类型是 std::optional<Student>,它本身并没有类型窄化的机制。即使在 if (student) 判断之后,student 的类型仍然是 std::optional<Student>,因此编译器无法在编译期判断是否安全访问其成员。在类型层面上,C++ 允许你访问 std::optional 的成员,但在运行时,如果 std::optional 没有值,就会触发断言失败或未定义行为。
表达「失败及其原因」
解析分数可能因为文本不是数字,也可能因为数字超出范围。单个空状态无法说明原因。
C++23 提供了 std::expected<T, E>,表示成功时保存 T,失败时保存错误 E:
#include <charconv>
#include <expected>
#include <string>
#include <string_view>
std::expected<int, std::string> parse_score(std::string_view text) {
int score{};
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), score);
if (error != std::errc{} || end != text.data() + text.size()) {
return std::unexpected{std::string{"Score is not an integer"}};
}
if (score < 0 || score > 100) {
return std::unexpected{
std::string{"Score must be between 0 and 100"}};
}
return score;
}NOTE
std::expected 属于 C++23,不是本系列 C++20 主线的必要条件。在 C++20 项目中,可以使用成熟的第三方实现、定义结果类型,或根据项目约定使用异常。第三篇会系统讨论错误处理。
错误处理方式没有一种适合所有情况。接口至少应让调用者知道失败是否可能发生,不要同时用返回值、输出参数和隐藏的全局状态表达同一个结果。
一张初步选择表
| 想表达的含义 | 常见写法 |
|---|---|
| 输入一个容易复制的值 | T |
| 借用并只读一个必有对象 | const T& |
| 借用并修改一个必有对象 | T& |
| 借用一个可能为空的对象 | T* 或 const T* |
| 借用连续数据 | std::span<T> |
| 借用只读文本 | std::string_view |
| 产生一个结果 | 返回 T |
| 可能没有正常结果 | std::optional<T> |
| 可能失败且需要原因 | std::expected<T, E>、异常或项目结果类型 |
| 转移独占所有权 | 按值传递 std::unique_ptr<T> 等所有者类型 |
这是一套默认起点,不是不可违反的法律。确认性能问题后可以进一步设计重载、转发引用等接口,但不应在没有测量时先牺牲可读性。
小结
- 函数声明是契约,应明确数据如何流动
- 小对象按值输入,大对象通常用
const引用借用 - 普通引用表示可能修改,指针可以表达可空借用
- 优先返回结果,不要默认使用输出参数
std::optional表达正常的缺失,不提供错误原因- C++23 的
std::expected可以同时表达结果和错误 - 所有权发生变化时,应让参数或返回类型明确说明