栈与函数调用:局部变量为什么会消失 / Stack and Function Calls
栈不是“放局部变量的地方”这么一句话就够了。
它更像函数调用的工作区。理解这点,才能理解局部变量、返回、递归、悬垂指针和栈溢出。
1. 函数调用需要保存现场
当一个函数调用另一个函数时,程序必须记住:
调用完以后回到哪里
当前函数还有哪些局部状态
被调用函数需要哪些参数和临时空间例如:
int square(int x) {
return x * x;
}
int sum_square(int a, int b) {
return square(a) + square(b);
}
int main() {
return sum_square(3, 4);
}调用过程可以简化成:
main()
|
v
sum_square(3, 4)
|
+-- square(3)
|
+-- square(4)运行时通常会有一叠调用帧:
when square(3) is running
+------------------+
| frame: square |
| x = 3 |
+------------------+
| frame: sum_square|
| a = 3, b = 4 |
+------------------+
| frame: main |
+------------------+square(3) 返回后,它自己的调用帧就撤掉。
这就是“栈”这个模型最核心的地方:
后调用的函数先返回。
函数返回时,它的本次工作区随之失效。2. 局部自动对象跟随作用域结束
看代码:
void f() {
int x = 42;
}x 是局部自动对象。
“自动”不是说它是自动变量这个老术语,而是强调:
进入作用域时自动创建
离开作用域时自动销毁更具体地说:
进入 f()
|
v
为 x 准备存储
|
v
x 的生命周期开始
|
v
离开 f()
|
v
x 的生命周期结束对 int 这种类型,生命周期结束看起来没有明显动作。
对 std::string 就不同:
#include <string>
void f() {
std::string s = "hello";
}离开函数时,s 的析构函数会执行。
析构函数会释放它拥有的字符资源。
3. 返回局部变量地址为什么危险
经典错误:
int* bad() {
int value = 42;
return &value;
}过程是:
bad() 被调用
|
v
value 在 bad() 的调用帧里创建
|
v
return &value
|
v
bad() 返回,调用帧撤掉
|
v
返回出去的地址还在
|
v
但地址上没有活着的 int 对象图像化:
inside bad()
+------------------+
| frame: bad |
| value = 42 <---+ |
+----------------| +
|
return pointer -+
after bad() returns
+------------------+
| frame: caller |
+------------------+
old frame of bad is gone
pointer still stores an address, but object lifetime ended这就是悬垂指针。
“悬垂”可以理解成:它还挂着一个地址,但这个地址不再对应合法对象。
引用也一样:
int& bad_ref() {
int value = 42;
return value;
}引用不是魔法保护罩。
它也不能让局部对象延长生命周期到函数外。
4. 作用域和生命周期相关但不完全相同
大多数局部变量离开作用域时生命周期结束。
但不要把两个词完全画等号。
作用域是名字的可见范围。
生命周期是对象是否活着。
int main() {
int* p = nullptr;
{
int x = 1;
p = &x;
}
// x 这个名字不可见
// x 对象生命周期也结束
// p 仍保存旧地址
}这里有三件事:
x 这个名字不能用了
x 这个对象也不活了
p 这个指针变量还活着错误经常发生在第三件事:保存旧地址的对象还活着,但它指向的对象不活了。
5. 递归和栈空间
递归函数每调用一层,就需要一层调用帧。
int factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}调用 factorial(4) 时:
factorial(4)
|
v
factorial(3)
|
v
factorial(2)
|
v
factorial(1)栈上可能像这样:
+------------------+
| factorial n = 1 |
+------------------+
| factorial n = 2 |
+------------------+
| factorial n = 3 |
+------------------+
| factorial n = 4 |
+------------------+
| main |
+------------------+如果递归太深,就可能栈溢出。
栈溢出不是 C++ 语法错误。
它是运行时栈空间不够了。
6. 大对象不要随便放栈上
看代码:
void f() {
int values[10'000'000]{};
}这个数组很大。
如果放在栈帧中,可能直接导致栈溢出。
更合适的方式通常是:
#include <vector>
void f() {
std::vector<int> values(10'000'000);
}这里 values 对象本体仍然是局部对象。
但大量元素存储通常来自堆。
它离开作用域时,vector 析构函数会释放元素缓冲区。
7. 栈对象也可以安全返回
不要误解为“局部变量都不能返回”。
不能返回的是局部对象的地址或引用。
返回值本身通常是安全的:
#include <string>
std::string make_name() {
std::string s = "Alice";
return s;
}这里返回的是一个 std::string 值。
编译器可以做返回值优化,也可以移动构造。
调用者得到的是自己的对象。
危险的是这种:
const std::string& bad() {
std::string s = "Alice";
return s;
}返回引用不会复制出新对象。
它只是让外面引用一个即将消失的对象。
8. 本篇总结
栈的核心模型是:
函数调用
|
v
创建调用帧
|
v
局部自动对象在调用帧中存在
|
v
函数返回
|
v
调用帧撤销,局部对象生命周期结束判断栈相关错误时问:
这个地址来自哪个函数的局部对象?
那个函数是否已经返回?
这个引用是否指向已经离开作用域的对象?
这个局部数组是否太大?
递归深度是否可能耗尽栈?下一篇讲堆与动态分配。堆解决的是“对象需要更灵活地存在”,但它也带来“谁负责释放”的问题。