一个结账程序刚开始也许只有十几行:读入单价和数量,算出金额,再打印结果。需求继续增加后,校验、折扣、运费和输出格式都挤进 main,同一段计算还可能被复制到别处。代码仍能运行,却越来越难回答三个问题:某段代码究竟负责什么、它依赖哪些输入、它会改动哪些对象。
函数把一项计算或动作单独命名,并为它规定输入与结果。调用者通过接口使用函数,不必同时阅读函数体。接口越准确,编译器能替我们检查的错误就越多;函数越专注,测试和修改的范围也越小。
下面从一次普通调用开始,逐步建立声明、参数、返回值、作用域、重载和回调的完整心智模型。所有可运行示例都按 C++17 编写,可以使用以下选项编译:
clang++ -std=c++17 -Wall -Wextra -Wpedantic main.cpp -o app也可以把 clang++ 换成 g++。
先看一个最小函数定义:
int order_total(int unit_price, int count) {
return unit_price * count;
}它可以从左到右读成一句话:order_total 接收两个 int,完成计算后返回一个 int。
函数定义包含四个部分:
int 是返回类型,规定调用表达式产生什么类型的值。order_total 是函数名,表达这项计算的意图。(int unit_price, int count) 是形参列表,规定调用所需的输入。{ ... } 是函数体,放置这次调用真正执行的语句。不需要结果的动作使用 void 返回类型。不需要输入的函数仍要保留一对空括号,例如 int current_year()。
下面是一个完整程序:
#include <iostream>
int order_total(int unit_price, int count) {
return unit_price * count;
}
void print_total(int total) {
std::cout << "bill = " << total << '\n';
}
int main() {
int tea_price{12};
int
输出为:
bill = 36unit_price 和 count 出现在函数接口中,它们是形参。tea_price 和 cups 出现在调用表达式中,它们是实参。两个概念不要按名字区分,而要按位置区分。
一次调用可以按下面的顺序理解:
先计算调用表达式中的实参。这里得到 tea_price 的值 12 和 cups 的值 3。
再用实参初始化本次调用的形参。形参属于这次调用自己的局部状态。
随后执行函数体。order_total 计算 unit_price * count,得到 36。
最后由 return 产生结果,控制权回到调用点,结果用于初始化 bill。
函数名本身不是一次调用。order_total 表示这个函数,而 order_total(12, 3) 才调用它。实参数量或类型不符合接口时,编译器会拒绝代码。
order_total(); // 错误:缺少两个实参
order_total(12); // 错误:缺少一个实参
order_total(12, 3, 1); // 错误:实参过多一个好函数通常只完成一个逻辑动作。计算金额和打印金额是两件事,所以示例把它们分成 order_total 与 print_total。这样可以单独测试计算,也可以在不改计算的情况下更换输出格式。
C++ 在使用名字之前需要先看到它的声明。函数声明告诉编译器如何调用函数,但不提供函数体:
int order_total(int unit_price, int count);末尾的分号很关键。它表示这里到此为止,只公布接口。
函数定义则提供实现:
int order_total(int unit_price, int count) {
return unit_price * count;
}定义本身也完成了声明,因此函数定义写在调用点之前时,可以直接调用。反过来,如果定义放在后面,就要先写一个声明:
#include <iostream>
int order_total(int unit_price, int count);
int main() {
std::cout << order_total(12, 3) << '\n';
}
int order_total(int unit_price, int count) {
return unit_price * count;
声明使编译器能够检查调用;定义使链接器最终找到可执行的函数体。只有声明而没有定义时,单个源文件可能编译成功,但生成程序的链接步骤会失败。
多个源文件共同使用一个函数时,通常把声明放进头文件,把定义放进一个 .cpp 文件。
pricing.h:
#ifndef PRICING_H
#define PRICING_H
int order_total(int unit_price, int count);
#endifpricing.cpp:
#include "pricing.h"
int order_total(int unit_price, int count) {
return unit_price * count;
}main.cpp:
#include "pricing.h"
#include <iostream>
int main() {
std::cout << order_total(12, 3) << '\n';
}实现文件也包含自己的头文件。这样,若定义意外写成 double order_total(double, int),编译器会立刻发现它与公开声明不一致。
可以分两步构建:
clang++ -std=c++17 -Wall -Wextra -Wpedantic -c pricing.cpp -o pricing.o
clang++ -std=c++17 -Wall -Wextra -Wpedantic -c main.cpp -o main.o
clang++ main.o pricing.o -o shop
./shop输出为:
36-c 只编译,不链接。前两条命令分别生成目标文件,第三条命令才把它们组合成可执行程序。
如果最后只链接 main.o,诊断中的关键信息会类似:
undefined symbol: order_total(int, int)
linker command failed这不是“声明没写”的编译错误,而是“实现没参加链接”的链接错误。排查时先看失败阶段,可以少走很多弯路。

pricing.h 提供声明,pricing.cpp 提供定义,main.cpp 发起调用;源文件分别编译为目标文件并由链接器合并。下方展示实参求值、形参初始化、执行函数体以及 return 返回调用点的完整顺序。普通函数的定义不要直接放进会被许多源文件包含的头文件,否则容易产生重复定义。确实需要在头文件定义的小函数,要使用后文介绍的 inline 或 constexpr 规则。
形参是本次调用的局部名字,但它与实参的关系取决于参数类型。最常见的三种形式可以先这样选择:
int score 是值形参。调用时它得到实参值的副本,函数内修改的是副本:
void add_bonus_copy(int score) {
score += 5;
}小型标量类型,如 int、double 和 bool,通常按值传递最清楚。函数若本来就需要一份独立可修改的副本,也应按值传递。
int& score 是引用形参。它是调用者对象的别名,赋值会作用到原对象:
void add_bonus(int& score) {
score += 5;
}非 const 引用表达了明显的副作用。看到这样的接口,调用者应预期传入对象会改变。
大型对象只需要读取时,const T& 可以避免复制,同时禁止函数通过形参修改对象:
void print_player(const std::string& name, int score) {
std::cout << name << ": " << score << '\n';
}下面的程序把三种形式放在一起:
#include <iostream>
#include <string>
void add_bonus_copy(int score) {
score += 5;
std::cout << "inside copy = " << score << '\n';
}
void add_bonus(int& score) {
score += 5;
}
void print_player(
输出为:
inside copy = 85
after copy = 80
Lin: 85
T& 是可写别名,修改会作用到原对象;const T& 共享读取原对象,但不能通过形参写入。第一次调用只改变副本,所以调用后的 score 仍是 80。第二次调用通过引用改动原对象,所以最终打印 85。
非 const 左值引用通常只能绑定到可修改对象,不能直接接收字面量:
void add_bonus(int& score);
int score{80};
add_bonus(score); // 正确
add_bonus(80); // 错误:80 不是可修改对象const 引用则可以绑定临时值,所以 print_player("Lin", score) 是合法的。const 只限制函数通过这个形参写入,不表示调用者的原对象一定被声明成了常量。
一个实用选择顺序是:小型输入按值传递;大型只读输入按 const& 传递;只有函数确实要修改调用者对象时才使用非 const&。如果一个新值能清楚表达结果,优先返回它。
return expression; 会产生函数的结果。可以把它理解成:用 expression 初始化函数声明所规定类型的返回值,再把结果交给调用点。
按值返回通常最安全,因为调用者得到自己的结果对象。即使返回表达式引用了局部变量,返回的也是它的值:
#include <iostream>
#include <string>
std::string make_label(const std::string& product, int count) {
std::string label{product + " x " + std::to_string(count)};
return label;
}
int main() {
std::string label{make_label("Tea", 3
输出为:
Tea x 3make_label 返回后,函数里的局部对象 label 被销毁,但返回值已经安全地交给调用者。现代 C++ 会对这类返回做消除复制或移动等优化;不要为了猜测复制成本而改成危险的引用返回。
除 main 外,返回值函数不得沿正常控制流抵达函数体末尾。某条路径可以用 return 产生结果,也可以用 throw 等不会返回调用点的方式终止;并非每条路径都必须执行带值的 return。main 是特例,正常抵达末尾等价于执行 return 0;。
下面的 sign 遗漏了 value == 0 的路径,执行这条路径时会正常流到函数体末尾:
int sign(int value) {
if (value < 0) {
return -1;
}
if (value > 0) {
return 1;
}
// 错误:value 为 0 时正常控制流抵达函数体末尾
}这里应明确补上 return 0;;如果零值代表无法继续处理的错误,也可以抛出异常。警告选项能发现许多遗漏,但函数编写者仍要检查每条路径最终是返回结果、以不返回方式终止,还是错误地落到末尾。
函数调用结束时,普通局部对象的生命周期结束。下面返回的引用会立即悬空:
const int& bad_ref() {
int local{42};
return local;
}运行 clang++ -std=c++17 -Wall -Wextra -Wpedantic -c bad_ref.cpp -o bad_ref.o 时,Clang 给出警告,但仍成功生成目标文件:
warning: reference to stack memory associated with local variable 'local' returned [-Wreturn-stack-address]如果希望把这项警告提升为编译错误,可以显式加入对应的 -Werror 选项:
clang++ -std=c++17 -Wall -Wextra -Wpedantic -Werror=return-stack-address -c bad_ref.cpp -o bad_ref.o此时核心诊断变为:
error: reference to stack memory associated with local variable 'local' returned [-Werror,-Wreturn-stack-address]不要运行这类程序来“看看会输出什么”。悬空引用一旦被使用,行为没有可靠结果。正确写法是直接 return local;,并把返回类型改成 int。
一次函数调用通常可以想成一层调用记录,其中包含形参、局部自动对象和返回位置。被调函数返回时,这一层被移除。这个模型能解释为什么不同调用各有自己的局部变量,也能解释为什么局部对象的引用不能逃出调用。

main 外,返回值函数可由 return 或 throw 结束路径,但不能沿正常控制流落到末尾。下面的观察器把一次调用拆成五个阶段。可以切换按值、可修改引用与 const 引用,逐步观察 caller、形参和局部状态如何变化;返回方式对比只模拟结果,不会真正读取悬空引用。
引用不拥有目标对象。返回引用之前必须回答:目标对象在调用结束后还活着吗?如果答案依赖运气、某次编译结果或某个地址看起来没变,这个接口就是错误的。
作用域回答“名字在哪里可见”,生命周期回答“对象在哪段时间内存在”。普通局部变量常让两者看起来同步,但局部 static 是一个明显例外。
函数形参和函数体内定义的普通变量拥有局部作用域。每次调用都会得到新的一份,调用结束后相应对象被销毁:
int doubled(int value) {
int result{value * 2};
return result;
}相同的 doubled(7) 调用两次,两次的 value 和 result 是不同对象,只是值相同。
在局部定义前加 static 后,对象只在第一次执行到该定义时初始化,后续调用复用同一个对象:
#include <iostream>
int next_ticket() {
static int current{100};
return ++current;
}
int doubled(int value) {
int result{value * 2};
return result;
}
int main() {
std::cout << next_ticket() << ' '
<<
输出为:
101 102 103
14 14current 的名字只能在 next_ticket 中使用,但对象一直存活到程序结束。它让函数记住历史,因此相同调用并不总得到相同结果。
局部静态对象也能安全地成为引用目标:
const std::string& ready_text() {
static const std::string text{"ready"};
return text;
}这里 text 在函数返回后继续存在,返回 const& 不会悬空。可是调用者会依赖共享对象,只有确实需要复用较大且稳定的数据时才这样设计。小型结果仍优先按值返回。
另一个作用域问题是遮蔽。内层重新使用外层名字虽然合法,却让读者难以判断当前操作的是谁:
int total{10};
if (total > 0) {
int total{20}; // 遮蔽外层 total,应避免
std::cout << total << '\n';
}尽量把名字定义在最小需要范围内,同时避免用同名局部对象制造多层含义。可变全局对象的范围更大,谁在何处修改它更难追踪,通常不应拿它替代函数参数和返回值。
多个函数可以同名,只要参数列表能形成不同的候选。这称为函数重载:
void describe(int value);
void describe(double value);调用时,编译器根据实参选择唯一的最佳匹配。可以先掌握一个实用的简化顺序:精确匹配通常优于类型提升,类型提升通常优于其他标准转换。
下面同时演示重载与默认实参:
#include <iostream>
void describe(int value) {
std::cout << "int: " << value << '\n';
}
void describe(double value) {
std::cout << "double: " << value << '\n';
}
int shipping_fee(
输出为:
int: 7
double: 7.5
fee A = 12
fee B = 0整数 7 精确匹配 describe(int),小数 7.5 精确匹配 describe(double)。shipping_fee(80) 省略第二个实参,于是使用默认值 100;第二次调用显式提供 60,覆盖了默认值。
下面两个声明不能共同存在:
int parse(const std::string& text);
double parse(const std::string& text); // 错误:参数列表相同调用 parse(text) 时,返回类型不足以让普通重载决议选出一个版本。需要不同语义时,应使用不同函数名或不同参数类型。
void pick(int, double);
void pick(double, int);
pick(1, 1); // 错误:两个候选各自在一个参数上更好严格编译得到的核心诊断是:
error: call to 'pick' is ambiguous解决歧义时不要随意强制转换来压住诊断。先检查两个重载是否表达了过于接近的含义,能否改名或简化接口。
int shipping_fee(int amount, int free_threshold = 100); // 正确
int broken(int amount = 0, int free_threshold); // 错误如果声明在头文件、定义在源文件,默认值通常只写在头文件声明中。调用代码在编译时需要看到默认值,定义处不要重复指定。
默认实参可以减少一组只负责补固定值的重载,但二者混用可能制造歧义:
void report(int value);
void report(int value, int detail = 0);
report(7); // 错误:两个候选都可调用默认值应代表稳定、自然的常用选择。若不同取值会切换明显不同的行为,用清楚的函数名往往比让调用者猜一个布尔值或数字默认参数更容易维护。
很短的函数常放在头文件中。若普通函数定义被多个源文件同时包含,会产生多份定义。inline 允许同一份一致的函数定义出现在多个翻译单元中:
// math_tools.h
#ifndef MATH_TOOLS_H
#define MATH_TOOLS_H
inline int doubled(int value) {
return value * 2;
}
#endifinline 的关键语言作用是支持这种定义组织。编译器是否真的在调用点展开函数体,是优化决定;关键字不会强制展开,也不能自动让一段复杂代码变快。
constexpr 表达另一种意图:当实参和使用场景都要求常量表达式时,函数可以在编译期求值;遇到运行期实参时,它也能像普通函数一样运行。
#include <iostream>
constexpr int square(int value) {
return value * value;
}
int main() {
static_assert(square(6) == 36);
constexpr int table_size{square(6)};
int input{7};
std::cout << table_size
输出为:
36
49static_assert 要求条件能在编译期确定,因此它直接检验 square(6)。input 是普通运行期变量,square(input) 仍然合法,只是不承担常量表达式的角色。
C++17 中,constexpr 函数不必只写一条 return,但它仍应适合常量求值。教学和接口设计中,可以先把它用于简短、确定、没有外部可见副作用的计算。
constexpr 函数本身隐含 inline 属性,所以这类小函数也常直接定义在头文件中。无论使用哪个关键字,不同翻译单元看到的定义都必须一致。
是否把函数写成 inline 或 constexpr,应由接口语义和定义位置决定。先写清楚的小函数,再让编译器负责具体优化。
普通调用由当前代码立即写出被调用函数名。另一种设计是先把“以后要调用哪个函数”交给通用代码,由它在合适时机调用。被交付的函数称为回调。
函数指针可以保存符合某个签名的函数地址。直接写类型比较难读,所以通常先起一个别名:
using Predicate = bool (*)(int);Predicate 表示:指向某个函数的指针,该函数接收一个 int,返回 bool。括号不能随意去掉,因为 * 与函数参数列表的结合方式会改变声明含义。
下面的程序把判断规则以回调形式交给遍历函数:
#include <iostream>
#include <string>
#include <vector>
using Predicate = bool (*)(int);
bool is_even(int value) {
return value % 2 == 0;
}
bool is_positive(int value) {
return value > 0;
输出为:
even: -2 0 4
positive: 1 4
int / double 重载的唯一最佳匹配与默认阈值补位,右侧展示 Predicate 回调插槽、两种兼容规则及错误签名拒绝。下面的实验室把三类接口判断放在一起:先比较重载候选的转换等级,再检查返回路径是否有效终止,最后用同一组数据观察不同回调规则。
print_matching 不知道具体规则,只知道 accept 可以接收一个 int 并返回真假。第一次调用传入 is_even,第二次传入 is_positive,遍历流程无需复制。
函数名在这里可转换成相应函数指针。也可以显式保存并调用:
Predicate check{is_even};
bool result{check(4)};签名必须兼容。返回 int 或接收两个参数的函数不能直接放进 Predicate。函数指针还可能是 nullptr,所以允许空回调的接口应像示例一样先检查;如果空值没有合理含义,则应从设计上要求调用者始终提供有效函数。
函数指针只是工具,接口是否清楚仍取决于函数设计。写一个小函数时,可以逐项检查:
order_total 比 process 更具体。const&。const& 参数是否真的需要修改调用者,而且函数名能否提示这种修改。可以把前面的订单示例扩展成三个文件,并按下面的顺序完成:
pricing.h 声明金额计算、运费计算和一个 Predicate 类型。pricing.cpp 定义函数,并包含 pricing.h 检查接口一致性。const std::string& 传入。Predicate,分别测试“金额为偶数”和“金额为正数”的规则。pricing.o,确认能识别链接错误。做到这里,函数就不只是“把代码包起来”。它同时划定依赖、修改范围和结果,让编译器能够检查调用,也让测试拥有明确边界。