0.3. 局部内存(Local Memory)¶
0.3.1. 感谢内存:分配与释放(Thanks For The Memory: Allocation and Deallocation)¶
局部变量 是每个人都在使用、 却没人去思考的编程结构。 你在初次掌握语法时会稍微想一想它们。 但几周之后,这些变量就变得非常自动,你很快 就忘了去思考它们是如何工作的。 这种状况要归功于现代编程语言—大多数 时候,变量在你需要时会自动出现, 在你用完时会自动消失。 对于基础编程,这是一种很好的状态。 然而,对于高级编程,了解一下 变量是如何工作的将很有用……
变量代表计算机内存中的存储空间。 每个变量在源代码中都提供一个方便的名称,比如 length 或 sum。 在运行时幕后,每个变量都使用计算机的一块 内存来存储它的值。 并不是程序中每个变量都有一块永久的 内存区域。 相反,现代语言很聪明,只在必要时 才把内存给一个变量。 术语是这样的:当变量被给予一块内存来存储其值时, 就称这个变量被 分配 了。 当变量被分配时,它可以像通常 那样作为变量来持有值。 当系统从变量收回内存 时,就称这个变量被 释放 了, 因此它不再有存储其值的区域。 对一个变量而言,从分配到 释放之间的这段时间称为它的 生存期 。
最常见的与内存相关的错误是使用已释放的变量。 对于局部变量,现代语言会自动防护 这一错误。 然而,正如我们将看到的,使用指针时,程序员必须确保 分配被正确处理。
0.3.2. 局部内存¶
你使用的最常见的变量是函数内的 局部变量,
比如下面函数中的变量 num 和 result。
一个函数所有的局部变量和参数合在一起,称为它的
局部存储 ,或者简称 "locals",
比如下面代码中的 num 和 result……
// Local storage example
int Square(int num) {
int result;
result = num * num;
return result;
}
这些变量之所以称为 "局部"(local),是为了表达这样一个想法:它们的生存期与声明它们的
函数绑定在一起。每当函数运行,它的局部变量就会被
分配。当函数退出,它的局部变量就会被释放。对于上面的例子,这就
意味着当 Square() 函数被调用时,会为
num 和 result 分配局部存储。像 result = num * num; 这样的语句在函数中
使用局部存储。当函数最终退出时,它的局部存储被释放。
下面是局部存储规则的更详细版本:
当函数被调用时,会为它的所有 局部变量分配内存。换句话说,当控制流到达函数的起始
{时,它的所有局部变量都会被分配 内存。上面例子中的参数(如 num)和局部变量(如 result) 都算作局部变量。参数和局部变量的 唯一区别在于,参数 一开始持有从调用方复制来的值,而局部变量 一开始持有随机的初始值。这篇文章的例子大多使用简单的int变量,不过局部分配适用于 任何类型:结构体、数组……这些都可以在局部进行 分配。- 只要控制线程还在所属函数内,局部变量的内存就
会持续处于已分配状态。即使函数暂时把控制线程 让给另一个函数,局部变量也继续 存在。在这整个过程里,局部变量都安然无恙地存在。
- 最后,当函数结束并退出时,它的局部变量被
释放。从某种意义上说这说得通—假使局部变量 不知何故继续存在—代码又怎么可能引用 它们呢?像
num和result这样的名称只在Square()的函数体内才有意义。一旦控制流离开 那个函数体,即使局部变量仍然 被分配,也根本无法引用它们。局部变量只在 所属函数内可用("作用域内"),这被称为 词法作用域, 几乎所有的语言现在都是这样做的。
0.3.3. 示例(Examples)¶
下面是一个关于局部存储生存期的简单示例。
void Foo(int a) {
// (1) Locals (a, b, i, scores) allocated when Foo runs
int i;
float scores[100];
// This array of 100 floats is allocated locally.
a = a + 1;
// (2) Local storage is used by the computation
for (i=0; i<a; i++) {
Bar(i + a); // (3) Locals continue to exist undisturbed,
} // even during calls to other functions.
} // (4) The locals are all deallocated when the function exits.
下面是一个更大的例子,展示 "局部变量在其函数开始运行时 被分配、在其退出时被释放" 这条简单规则如何构建更复杂的行为。 要理解后面模块的内容,你需要牢牢掌握局部分配的工作原理。 这幅图显示了当函数 X() 两次调用函数 Y() 时 所产生的分配与释放序列。 时间点 T1、T2 等标记在 代码中,图中显示了当时内存的状态。
void X() {
int a = 1;
int b = 2;
//T1
Y(a);
//T3
Y(b);
//T5
}
void Y(int p) {
int q;
q = p + 2;
//T2 (first time through), T4 (second time through)
}
(可选额外内容…)这幅图显示了局部变量被分配和 释放的序列—实际上,这幅图展示的是 运行时栈 随时间推移的工作过程, 它是系统用来实现局部存储的数据结构。
0.3.4. 局部参数(Local Parameters)¶
局部变量与其函数紧密关联—它们
只在函数内使用,不会在别处使用。
只有 X() 的代码可以引用它的 a 和 b。
只有 Y() 的代码可以引用它的 p 和 q。
局部存储的这种独立性,正是其
优点和缺点的根源。
0.3.4.1. 局部变量的缺点(Disadvantages Of Locals)¶
局部变量对于程序 90% 的内存需求来说都很合适:
方便。局部变量满足一种方便的需求—函数往往 需要一些临时的内存,只在函数的 计算期间存在。局部变量方便地提供了这种 临时的、独立的内存。
高效。相对于其他内存使用技术,局部变量非常 高效。为它们分配和释放内存很节省时间(快), 而且它们在使用和回收内存的方式上很节省空间。
局部副本。局部参数基本上是调用方信息的局部副本。 这也被称为 值传递 。 参数是局部变量,用调用方的 赋值(
=)操作初始化。在指针的意义上,调用方并没有 与调用者 "共享" 参数值—被调用方得到的是自己的一份副本。这样做的好处是, 被调用方可以修改它的局部副本而不影响调用方。 (比如上面例子中的p参数。)这种 独立性很好,因为它让调用方和被调用方 函数彼此独立地运行,这符合良好软件 工程的原则—让独立的组件尽可能相互独立。
0.3.4.2. 局部变量的缺点(Disadvantages Of Locals)¶
局部变量有两个缺点:
生存期短。它们的分配和释放安排(它们的 "生存期")非常严格。有时程序需要 在最初分配它的函数退出之后 内存仍然保持已分配状态。局部变量做不到这点,因为它们 在所属函数 退出时会被自动释放。这个问题将在后面一节用 堆 内存解决。
通信受限。由于局部变量是调用方参数的副本, 它们不提供从被调用方 回到调用方的通信手段。这是 "独立性" 优势的缺点。而且,有时复制一个值 出于其他原因也是不可取的。我们将在 下一个模块中看到这个问题的解决方案。
0.3.4.3. "局部" 的同义词(Synonyms For "Local")¶
局部变量也称为 自动变量 ,因为它们的分配和释放是作为函数调用机制的一部分自动完成的。局部变量有时也称为栈变量(stack variables),因为在底层,语言几乎总是使用内存中的栈结构来实现局部变量。
0.3.4.4. 取地址符(&)bug—TAB¶
现在你理解了局部变量的分配安排,就能欣赏到 C 和 C++ 中一个比较丑陋的 bug。
下面的代码中,函数 Victim() 调用函数 TAB(),
它有什么问题?
要看懂这个问题,最好
画一幅图来追踪这两个函数的局部存储。
// TAB -- The Ampersand Bug function
// Returns a pointer to an int
int* TAB() {
int temp;
return(&temp);
// return a pointer to the local int
}
void Victim() {
int* ptr;
ptr = TAB();
*ptr = 42;
// Runtime error! The pointee was local to TAB
TAB() 在运行时实际上是正常的。问题发生在 TAB() 退出后对其调用者的影响上。 TAB() 返回一个指向 int 的指针,但该 int 是在哪里分配的?问题在于局部 int (即 temp )仅在 TAB() 运行时才被分配。当 TAB() 退出时,其所有局部变量都会被释放。因此,调用者最终得到的是一个指向已释放变量的指针。 TAB() 的局部变量在其退出时会被释放,这与前一个示例中 Y() 的局部变量发生的情况相同。 TAB() 返回一个即将被释放的内存指针是不正确且无用的。我们本质上遇到了局部变量的“生命周期”约束。我们希望该 int 存在,但它会自动被释放。并非所有函数间使用 & 的情况都是错误的—仅当用于向调用者回传指针时才出错。 & 的正确用法在第 3 节讨论,而向调用者回传指针的方法则在第 4 节展示。
0.3.4.5. 局部内存小结(Local Memory Summary)¶
局部变量就其作用而言非常方便—为函数提供方便高效的内存, 而这些内存只在函数执行期间存在。局部变量有 两个缺陷,我们将在后面的章节中解决—函数如何 与它的调用方通信(第 3 节),以及函数如何分配 一份生存期约束较宽松的独立 内存(第 4 节)。
0.3.5. 函数调用栈是如何工作的?(How Does The Function Call Stack Work?)¶
你不需要知道局部变量在函数调用期间是如何实现的,不过
如果你好奇的话,下面是步骤的粗略概述。实现的确切细节
依赖于语言和编译器。不过,下面这个基本结构
近似许多系统和语言所使用的方法……
要调用像 foo(6, x+1) 这样的函数:
在调用方的上下文中计算实际参数表达式,比如 x+1。
通过把一个大小合适的 "局部 块" 内存压入专用于此目的的运行时 调用栈,为
foo()的局部变量分配内存。 对于参数但不是局部变量,把步骤 (1) 中的值存入foo()的局部 块中适当的槽位。存储调用方当前的执行地址(它的 "返回 地址"),并把执行切换到
foo()。foo()执行,其局部块在调用栈 末端方便地可用。当
foo()完成时,它通过把它的局部变量从栈上弹出并 使用之前存储的 返回地址 "返回" 给调用方来退出。现在调用方的局部变量位于栈的末端, 它可以恢复执行。
对于极其好奇的读者,这里还有一些关于 函数调用过程的其他杂项说明:
这就是无限递归会导致 "栈溢出错误"—代码不停地调用再调用,不断重复步骤 (1) (2)
(3)、(1) (2) (3),却永远不会有步骤 (4) ……最终调用栈 用完了内存。
这就是局部变量的初始值随机的原因—步骤 (2) 只是把整个局部块分一次压栈。每个局部变量得到 自己的一块内存,但内存里会包含 最近一次使用它的 "人" 留下的任意内容。为每次函数调用都清空 整个局部块,就太耗时了。
"局部块" 也被称为函数的 活动记录 或 栈帧 。 整个块可以被压入 栈(步骤 2),这是一次单独的 CPU 操作—这是非常快的 操作。
对于多线程环境,每个线程有它自己的调用栈, 而不是只有一个全局的调用栈。
出于性能原因,有些语言把一些参数通过寄存器传递、另一些通过栈传递, 所以整个过程 很复杂。不过,变量的表面生存期 总是遵循这里给出的 "栈" 模型。
