Skip to content

2.1 如何设计一个函数接口 ​

我们已经会把一段代码装进函数,但一个函数是否容易使用,往往不取决于函数体写得多巧妙,而取决于调用者能否从声明中看出:它需要什么、会修改什么、可能失败吗、结果由谁负责。

cpp
bool find_student(int id, Student *result);

只看这个声明,就会冒出不少问题:

  • result 可以为空吗?
  • 函数会不会保存这个指针?
  • 找不到学生时,result 仍然有效吗?
  • 返回的 bool 表示是否找到,还是表示程序出错?

函数声明是调用者与实现者之间的契约。好的接口会尽量让类型替我们回答这些问题。

参数与数据流动 ​

最常见的参数可以先按「输入」「输出」「输入并输出」来理解。

小对象按值传递 ​

int、枚举、指针等容易复制的小对象,通常直接按值传入:

cpp
bool is_passing(int score, int passing_score) {
    return score >= passing_score;
}

函数得到参数的一份副本。修改副本不会影响调用者,也不存在引用悬空问题。没有必要为了更快而把 int 改成 const int&,通常这只会增加一次间接访问,并不会更快。

避免微优化

大家都希望程序尽可能高效地运行,但代码的整体清晰度和可维护性同样重要。

微优化是指对代码进行微小的改动以试图提升性能,但往往以牺牲可读性、可维护性甚至引入新瓶颈为代价。之所以说「微」优化,是因为这样的改动通常只节省极少量的时间或内存占用,或者甚至对性能毫无提升、乃至起到反效果。

大部分程序的性能瓶颈都不在这些小地方。例如输入输出、网络请求等昂贵操作的成本通常比微优化省下的高出几个量级。合理的做法是先用性能分析工具找出真实瓶颈,避免盲目优化。

基于公认的最佳实践编写代码,保持代码清晰、易维护。

较大的只读对象使用 const 引用 ​

如果函数只读取一个复制成本较高的对象,可以使用 const T&:

cpp
#include <iostream>
#include <string>

void print_name(const std::string &name) { std::cout << name << '\n'; }

这里的引用表示借用,const 表示函数不会通过这个接口修改字符串。引用必须绑定到对象,自然而然地避免了指针参数收到空指针 nullptr 等问题;const 引用也可以绑定到临时对象,所以这个对象不一定有名字。

修改调用者对象时使用普通引用 ​

普通引用参数明确表示函数可能修改对象:

cpp
void add_bonus(Student &student, int bonus) { student.score += bonus; }

调用处能看到传入的是一个对象,却不能像某些语言那样从语法上直接看出它会被修改。因此函数名、参数名和文档仍然要写清楚。

如果函数只为产生一个新值,返回结果通常比修改输出参数更自然:

cpp
int with_bonus(int score, int bonus) { return score + bonus; }

可空的借用可以使用指针 ​

引用表达「一定有对象」,指针则可以额外表达「可能没有对象」:

cpp
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& 参数通常只借用对象。把所有权交给函数时,应使用值、资源管理类型或智能指针明确表达,不能要求调用者猜测谁负责释放。

连续数据不要只传首地址 ​

传统接口常把数组拆成指针和长度:

cpp
double average(const int *scores, std::size_t count);

两个参数必须始终匹配,但类型系统不知道它们之间的关系。第一章介绍的 std::span 能把它们合为一个范围:

cpp
#include <span>

double average(std::span<const int> scores);

类似地,只读字符序列常用 std::string_view 借用。它们都不延长底层对象的生命周期。

返回值优先 ​

过去有人担心返回 std::string、std::vector 等对象会产生昂贵复制,于是偏爱输出参数:

cpp
void make_report(const Student &student, std::string &output);

现代 C++ 通常直接返回结果:

cpp
#include <string>

std::string make_report(const Student &student) {
    return student.name + ": " + std::to_string(student.score);
}

编译器可以进行复制消除;不能消除时,也通常可以使用移动语义转移结果中的资源。因此不要为了「避免复制」而写出难以理解的接口:

cpp
std::string make_name() {
    std::string result{"Alice"};
    return result; // 正常返回,让编译器进行复制消除或移动
}

返回多个相关结果时,可以定义一个有名字的结构体:

cpp
struct Statistics {
    double average{};
    int minimum{};
    int maximum{};
};

Statistics calculate_statistics(std::span<const int> scores);

这比让调用者准备三个可修改引用更容易读,也为以后增加字段留下了位置。而且调用者可以通过 auto 方便地承接返回值。

用类型表达缺失与失败 ​

表达「可能没有」 ​

魔法值会把正常数据和特殊状态混在一起:

cpp
int find_score(int id); // 返回 -1 表示没找到?

如果 -1 不是正常结果,勉强可以约定它表示失败,但调用者容易忘记检查。C++17 的 std::optional<T> 明确表示「可能包含一个 T,也可能为空」。

cpp
#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;
}

调用者必须面对这个额外状态:

cpp
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 表示。考虑下面的代码:

ts
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++ 中:

cpp
#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:

cpp
#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 可以同时表达结果和错误
  • 所有权发生变化时,应让参数或返回类型明确说明

延伸阅读:C++ Core Guidelines:函数参数传递。