函数、作用域与重载 / Functions, Scope, and Overloading
本文属于 C++ 语言基础模块的“函数和名字”部分。
我们先看一个具体问题:一个函数明明只是打印数据,为什么调用它之后容器可能变慢了?
然后从这个现象逐步走到参数传递、调用栈、作用域、生命周期、ABI 和重载。
1. 先看一个可能发生的复制
#include <iostream>
#include <string>
#include <vector>
void print_names(std::vector<std::string> names) {
for (const std::string& name : names) {
std::cout << name << '\n';
}
}
int main() {
std::vector<std::string> names{"Ada", "Grace", "Dennis"};
print_names(names);
}这段程序很短。
它也很容易让人忽略一个事实:
main 中的 names
|
v
按值传给 print_names
|
v
函数得到一个独立的 vector 对象
|
v
元素通常也需要被复制如果 names 只有三个字符串,这种复制不一定值得担心。
如果它有一千万个元素,就需要重新思考接口。
如果函数只是读取数据,可以写成:
void print_names(const std::vector<std::string>& names) {
for (const std::string& name : names) {
std::cout << name << '\n';
}
}这时函数借用调用者的对象:
调用者仍然拥有 names
|
v
print_names 通过 const 引用读取
|
v
函数返回后借用关系结束这里的核心不是“引用永远比值快”。
真正的问题是:
函数需要独立副本吗?
函数会修改调用者吗?
函数会保存这个对象吗?
对象可以不存在吗?函数签名其实是在描述这些契约。
2. 本文先建立一张地图
调用者准备实参
|
v
编译器检查声明和类型
|
v
ABI 决定参数和返回值如何传递
|
v
被调用函数建立自己的执行环境
|
v
函数读取参数、创建局部对象、产生结果
|
v
局部对象销毁,控制权返回调用者我们会在不同层次观察同一次调用:
C++ 语言层
+-- 函数声明、参数、返回值、重载
对象模型层
+-- 复制、引用、生命周期、所有权
实现层
+-- 寄存器、栈帧、返回地址、调用约定
工程层
+-- 接口清晰度、性能、错误路径、可测试性不要把“通常使用栈传参”当成语言规则。
C++ 规定的是语义。
具体平台的 ABI 再决定这些语义怎样落到寄存器和内存。
3. 本文基础词小注释
3.1 函数
函数是一段有名字的可调用代码。
它接受输入,执行动作,可能产生输出:
输入
|
v
函数
|
v
结果或副作用3.2 声明和定义
声明告诉编译器某个名字和类型:
int add(int left, int right);定义提供真正的实现:
int add(int left, int right) {
return left + right;
}3.3 实参和形参
调用时写出的值叫实参:
add(20, 22);函数声明中的名字叫形参:
int add(int left, int right);调用者的 20 -> 形参 left
调用者的 22 -> 形参 right3.4 作用域
作用域回答:
在源码的哪一段区域内,可以通过这个名字找到它?
3.5 生命周期
生命周期回答:
这个对象在运行时从什么时候开始存在,到什么时候结束?
名字的作用域和对象的生命周期有关,但不是一回事。
3.6 栈帧
栈帧是一次函数调用通常需要的一块执行状态区域。
它可能包含:
参数
局部变量
保存的寄存器
返回位置
临时状态这是实现层概念,不是 C++ 标准要求的固定布局。
3.7 ABI
ABI 是 Application Binary Interface,应用二进制接口。
它规定已经编译好的代码如何互相合作,例如:
参数放哪些寄存器
返回值放哪里
谁负责保存哪些寄存器
对象如何布局
符号如何命名3.8 重载
重载是同一个作用域中允许多个同名函数,只要参数列表能区分:
int area(int side);
double area(double width, double height);编译器在编译期选择具体哪个函数。
4. 从最简单的 add 开始
#include <iostream>
int add(int left, int right) {
return left + right;
}
int main() {
int result = add(20, 22);
std::cout << result << '\n';
}可以把执行过程写成:
main()
|
+-- 准备实参 20 和 22
|
+-- 调用 add
|
+-- left 取得 20
+-- right 取得 22
+-- 计算 left + right
+-- 产生 42
|
+-- result 取得返回值 42这里有三个值得区分的名字和对象:
main 的 result
add 的 left
add 的 right即使 left 和 right 是由实参初始化的,它们在语言上也属于函数调用中的形参对象。
5. 先理解按值传递
void increment(int value) {
++value;
}
int main() {
int number = 10;
increment(number);
std::cout << number << '\n';
}输出是:
10因为:
number == 10
|
v
初始化一个独立的 value == 10
|
v
value 变成 11
|
v
函数返回,value 生命周期结束
|
v
number 仍然是 10按值传递的关键语义是:
函数得到一个独立的参数对象。
对于 int,复制很便宜。
对于大型容器,复制可能很贵。
因此不能把按值传递简单说成“好”或“坏”。
要看类型和函数意图。
6. 引用传递:给函数一个别名
void increment(int& value) {
++value;
}
int main() {
int number = 10;
increment(number);
std::cout << number << '\n';
}这次输出:
11int& value 可以理解为:
value 不是另一个独立 int
value 是 number 的另一个名字number
^
|
value函数修改 value,调用者看到的就是同一个对象被修改。
6.1 引用表达什么
非常量引用通常表达:
调用者必须提供一个存在的对象
函数可能修改这个对象只读引用:
void print_name(const std::string& name);表达:
函数不通过 name 修改对象
函数不需要拥有对象
调用期间对象必须保持有效7. 指针参数和引用参数不是一回事
bool increment_if_present(int* value) {
if (value == nullptr) {
return false;
}
++*value;
return true;
}调用:
int number = 10;
increment_if_present(&number);
increment_if_present(nullptr);指针本身是一个对象。
它保存一个地址值,也可以保存“没有对象”的状态:
int* value
|
+-- 指向 number
|
+-- 或者等于 nullptr引用通常必须绑定到一个有效对象:
void use(int& value);不能用 nullptr 作为 int&。
7.1 三种接口的对照
T value
+-- 独立值
+-- 函数可修改自己的副本
T& value
+-- 调用者对象的别名
+-- 通常不能为空
+-- 可修改调用者对象
T* value
+-- 指针值本身按值传递
+-- 可以为空
+-- 解引用后可修改目标对象这不是只关于性能。
它是在写接口时表达不同的关系。
8. const 让借用关系更清楚
void print_names(const std::vector<std::string>& names) {
for (const std::string& name : names) {
std::cout << name << '\n';
}
}这里的 const 约束的是:
不能通过这个引用修改 names它不等于:
对象在全世界任何地方都绝对不会改变基础阶段先记住接口含义:
const T&
+-- 借用
+-- 只读
+-- 不接管所有权9. 返回值:为什么按值返回通常安全
std::string make_message() {
std::string text = "hello";
return text;
}很多初学者担心:
text是局部变量,函数结束后不是销毁了吗?
是的,局部 text 的生命周期会结束。
但按值返回的结果不是“把引用留在原来的局部变量上”。
函数内部 text
|
+-- 复制消除或移动
v
调用者得到独立的结果对象
|
v
局部 text 结束
结果对象仍然存在返回局部对象的引用则是错误:
const std::string& bad_message() {
std::string text = "hello";
return text;
}函数返回后:
引用仍然保存着原来的定位关系
|
v
原来的 text 已经结束生命周期
|
v
引用悬空悬空引用就是引用仍然保存着一个看似有效的定位关系,但目标对象已经不存在。
10. 调用栈:函数调用时机器需要保存什么
语言层的函数调用可以映射到实现层的执行状态。
int square(int value) {
int result = value * value;
return result;
}
int main() {
int answer = square(6);
}在没有复杂优化的直觉模型中:
main 的执行状态
|
+--> 调用 square
|
+-- 保存返回位置
+-- 传递 value
+-- 计算 result
+-- 产生返回值
|
+<-- 回到 main可以画成栈帧:
高地址
+---------------------------+
| main 的局部状态 |
| answer |
+---------------------------+
| square 的局部状态 |
| value = 6 |
| result = 36 |
| 返回位置 |
+---------------------------+
低地址这只是帮助理解的示意图。
实际实现可能把 value 和 result 放进寄存器,甚至直接把 square(6) 优化成常量 36。
11. ABI:调用者和被调用者怎样达成默契
如果一个函数由一个 .cpp 文件编译,另一个函数由另一个 .cpp 文件编译,它们仍然要能够合作。
这需要一套二进制层面的约定:
调用者
|
+-- 把参数放到约定位置
+-- 调用目标地址
|
v
被调用者
+-- 按同一约定读取参数
+-- 计算结果
+-- 按约定放回返回值这就是 ABI 参与的地方。
不同平台的 ABI 可能不同:
x86-64 Linux
+-- 一组寄存器和调用约定
Windows x64
+-- 另一组细节
RISC-V
+-- 又一组寄存器和规则所以 C++ 标准不承诺:
第一个参数一定放在某个寄存器但编译器和平台 ABI 必须有一致约定,已编译代码才能合作。
12. 用汇编观察一个简单函数
创建 square.cpp:
int square(int value) {
return value * value;
}先用最简单的命令生成汇编:
g++ -S square.cpp -o square.s命令拆解:
g++ 调用 C++ 编译器
-S 停在汇编文本阶段
square.cpp 输入源码
-o 指定输出文件
square.s 汇编文件名打开 square.s,不需要一开始读懂所有指令。
先找三件事:
函数标签在哪里
参数在哪里被使用
return 对应什么结束动作再用优化级别对比:
g++ -O0 -S square.cpp -o square-o0.s
g++ -O2 -S square.cpp -o square-o2.s-O0 表示尽量不做优化,方便观察。
-O2 表示启用一组常见优化。
不同编译器生成的汇编可能不同,但函数的可观察结果必须符合 C++ 语义。
13. 作用域:名字在哪里可见
int global_count = 1;
void demo(int parameter) {
int outer = 2;
{
int inner = 3;
std::cout << global_count + parameter + outer + inner << '\n';
}
// inner 在这里不可见
}可以分成几层:
全局/命名空间作用域
+-- global_count
函数参数作用域
+-- parameter
函数体局部作用域
+-- outer
内部代码块作用域
+-- inner作用域回答名字查找问题。
它不直接回答对象什么时候销毁。
14. 作用域和生命周期不是一回事
int next_id() {
static int id = 0;
return ++id;
}id 的名字只在函数内部可见。
但 id 对象不会每次调用都重新创建。
名字作用域:函数内部
对象存储期:整个程序运行期间
初始化:第一次经过声明时另一个例子:
const int& get_value() {
static int value = 42;
return value;
}这里返回引用是可能安全的,因为被引用的静态对象会一直存在。
但这种写法会引入隐藏状态:
调用顺序影响结果
测试之间可能互相影响
多线程修改需要同步15. 名字遮蔽:能编译不代表好读
int count = 1;
void demo() {
int count = 2;
std::cout << count << '\n';
}函数里的 count 会遮蔽外层的 count。
使用 count
|
v
先找最内层作用域
|
+-- 找到局部 count
|
v
不再继续找外层同名 count可以用 ::count 访问全局命名空间中的名字,但更好的工程习惯通常是减少同名遮蔽。
16. 默认参数不是运行时魔法
void connect(int timeout_ms = 1000, bool retry = true);调用:
connect();
connect(500);
connect(500, false);可以把它理解为调用点的补全:
connect()
-> connect(1000, true)
connect(500)
-> connect(500, true)默认参数通常由编译器在调用点处理。
因此库更新默认值后,已经编译好的调用方不一定自动改变。
17. 重载:编译器怎样选择同名函数
void print(int value);
void print(double value);
void print(const char* text);调用:
print(3);
print(3.14);
print("hello");编译器不是运行时猜测。
它在编译期大致经历:
找到名字 print
|
v
收集候选函数
|
v
检查参数数量和类型是否可行
|
v
比较需要的隐式转换
|
v
选出唯一最佳候选17.1 精确匹配优先
void show(long);
void show(double);
// show(1);1 的类型是 int。
它可以转换成 long,也可以转换成 double。
如果没有唯一最佳匹配,调用会产生二义性。
更明确的做法:
show(1L);
show(1.0);17.2 返回类型不能区分普通重载
错误:
// int parse();
// double parse();调用者只写:
parse();编译器不能靠接收结果的变量类型稳定地区分两个函数。
17.3 顶层 const 不产生值参数重载
void set(int value);
// void set(const int value); // 不能作为另一个重载因为按值传递时,形参本身是否 const 不改变调用接口的区分方式。
但指向 const 和非 const 对象的指针是不同类型:
void inspect(int* value);
void inspect(const int* value);18. 函数指针:把行为当作值
int add(int left, int right) {
return left + right;
}
int multiply(int left, int right) {
return left * right;
}
int apply(int (*operation)(int, int), int a, int b) {
return operation(a, b);
}调用:
int x = apply(add, 2, 3);
int y = apply(multiply, 2, 3);这里 operation 保存一个函数地址。
apply
|
+-- operation -> add
|
+-- 或 operation -> multiply函数指针是理解回调、事件循环和虚函数实现的基础。
19. 递归:调用栈一层层增加
unsigned long long factorial(unsigned int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}factorial(4) 的调用关系:
factorial(4)
|
+-- 4 * factorial(3)
|
+-- 3 * factorial(2)
|
+-- 2 * factorial(1)
|
+-- 1每一层调用都要保留自己的参数和返回位置。
所以递归深度太大可能耗尽调用栈。
逻辑上会终止,不等于资源上一定安全。
20. return 和局部对象销毁
#include <iostream>
struct Marker {
const char* name;
~Marker() {
std::cout << "destroy " << name << '\n';
}
};
void work() {
Marker first{"first"};
Marker second{"second"};
}离开 work() 时:
先销毁 second
再销毁 first局部对象通常按构造的逆序销毁。
这让资源管理可以形成:
进入作用域 -> 获得资源
离开作用域 -> 自动释放资源这就是后续 RAII 学习需要的基础。
21. noexcept 和函数契约
int square(int value) noexcept {
return value * value;
}noexcept 表示这个函数不应让异常传播出去。
如果异常试图逃出 noexcept 函数,程序会调用 std::terminate。
因此它是一个强承诺。
不要为了看起来高性能就到处添加。
22. [[nodiscard]]:提醒调用者不要丢结果
[[nodiscard]] bool save_file();如果调用者写:
save_file();编译器可能提醒结果被忽略。
这适合:
错误码
资源句柄
解析结果
纯计算结果23. 一个完整实验:按值、引用和指针
创建 parameters.cpp:
#include <iostream>
void by_value(int value) {
++value;
}
void by_reference(int& value) {
++value;
}
void by_pointer(int* value) {
if (value != nullptr) {
++*value;
}
}
int main() {
int number = 10;
by_value(number);
std::cout << "after value: " << number << '\n';
by_reference(number);
std::cout << "after reference: " << number << '\n';
by_pointer(&number);
std::cout << "after pointer: " << number << '\n';
}先编译:
g++ parameters.cpp -o parameters再运行:
./parameters预期观察:
after value: 10
after reference: 11
after pointer: 12这个实验验证的是接口语义,不是某个 ABI 的具体寄存器。
24. 一个完整实验:观察作用域和生命周期
创建 lifetime.cpp:
#include <iostream>
struct Marker {
const char* name;
Marker(const char* value) : name(value) {
std::cout << "construct " << name << '\n';
}
~Marker() {
std::cout << "destroy " << name << '\n';
}
};
void demo() {
Marker outer{"outer"};
{
Marker inner{"inner"};
std::cout << "inside block\n";
}
std::cout << "after block\n";
}
int main() {
demo();
}观察顺序:
construct outer
construct inner
inside block
destroy inner
after block
destroy outer25. 一个完整实验:观察重载选择
#include <iostream>
void show(int) {
std::cout << "int\n";
}
void show(double) {
std::cout << "double\n";
}
void show(const char*) {
std::cout << "text\n";
}
int main() {
show(1);
show(1.0);
show("hello");
}先预测输出,再编译运行。
然后添加:
void show(long) {
std::cout << "long\n";
}重新观察 show(1) 是否仍然明确。
添加一个重载,可能改变原有调用的候选集。
26. 常见误解
误解一:引用就是更安全的指针
不完全是。
引用通常不能表达空值,也不能像指针那样重新绑定。
误解二:按引用传递一定更快
不一定。
小型整数按值很自然,引用还可能引入别名关系。
误解三:函数返回局部对象一定危险
按值返回通常安全。
返回局部对象的引用或指针才是悬空风险。
误解四:作用域结束就一定是对象销毁
大多数普通局部对象确实如此,但局部 static 的对象存储期不同。
误解五:重载是在运行时选择
普通重载解析发生在编译期。
误解六:调用栈就是 C++ 标准规定的栈
调用栈是常见实现模型。
误解七:const 表示整个系统中对象永远不会变化
const 主要限制某条访问路径。
27. 函数设计时的检查顺序
1. 输入是独立值还是借用对象?
2. 函数是否会修改输入?
3. 参数允许为空吗?
4. 函数是否保存参数?
5. 返回值是新对象、引用还是指针?
6. 失败怎样表达?
7. 调用者是否必须检查结果?
8. 重载是否会产生隐式转换歧义?
9. 递归深度是否有边界?
10. ABI 或平台边界是否影响接口?函数签名不是装饰。
它是调用者和实现者之间的协议。
28. 本文和编译、内存文章的连接
上一篇讲的是:
源码
|
v
预处理
|
v
汇编
|
v
目标文件
|
v
链接本文继续追问:
目标文件中的函数
|
v
调用者怎样找到它
|
v
参数怎样传过去
|
v
返回值怎样回来下一篇数组、指针和字符串会继续追问:
函数参数中的地址到底是什么?
数组为什么连续?
指针为什么会悬空?29. 自测题
问题一
按值传递后,函数修改形参会不会改变调用者的 int?
**答案:**通常不会,因为形参是独立对象。
问题二
为什么 const T& 常用于只读的大对象?
**答案:**它借用调用者对象,不通过该引用修改,也避免建立完整副本。
问题三
为什么不能返回局部对象的引用?
**答案:**函数返回时局部对象结束生命周期,引用会悬空。
问题四
作用域和生命周期的区别是什么?
**答案:**作用域描述名字在源码中的可见区域,生命周期描述对象运行时存在的时间。
问题五
重载什么时候选择?
**答案:**普通重载在编译期根据静态类型和隐式转换选择。
问题六
ABI 解决什么问题?
**答案:**它规定编译好的代码如何在二进制层面传递参数、返回结果、布局对象和解析符号。
30. 本篇总结
函数可以同时从四个角度理解:
语言角度
+-- 名字、声明、定义、参数、返回值
对象角度
+-- 复制、借用、修改、生命周期
机器角度
+-- 寄存器、栈帧、返回地址、调用约定
工程角度
+-- 接口契约、失败路径、性能、可测试性最重要的学习顺序是:
先看函数需要什么输入和输出
|
v
再看参数是值、引用还是指针
|
v
再看对象什么时候开始和结束
|
v
最后再看 ABI 和汇编怎样实现不要把“按值、按引用、按指针”背成性能口诀。
先问它们表达什么关系:
值 独立副本
引用 必须存在的借用/别名
指针 可为空的地址值上一篇:表达式、运算符与控制流 | 下一篇:数组、指针与字符串