局部变量的基本概念
在函数体内部定义的变量被称为局部变量(与我们将在下一章讨论的全局变量相对):
int add(int x, int y)
{
int z{ x + y }; // z 是一个局部变量
return z;
}
函数参数通常也被认为是局部变量,我们将它们视为局部变量:
int add(int x, int y) // 函数参数 x 和 y 是局部变量
{
int z{ x + y };
return z;
}
在这节课中,我们将更详细地探讨局部变量的一些特性。
局部变量的生命周期
在课程 1.3 — 对象和变量简介中,我们讨论了如何通过 int x;
这样的变量定义语句在执行时实例化变量。函数参数在进入函数时被创建和初始化,函数体内的变量在定义点被创建和初始化。
例如:
int add(int x, int y) // 在这里创建和初始化 x 和 y
{
int z{ x + y }; // 在这里创建和初始化 z
return z;
}
随之而来的自然问题是,“那么,实例化的变量何时被销毁呢?“局部变量在定义它的大括号集合结束时(对于函数参数来说,是在函数结束时)以相反的创建顺序被销毁。
int add(int x, int y)
{
int z{ x + y };
return z;
} // 在这里销毁 z、y 和 x
就像一个人的生命被定义为从出生到死亡的时间一样,一个对象的生命周期被定义为从创建到销毁的时间。注意,变量的创建和销毁发生在程序运行时(称为运行时),而不是编译时。因此,生命周期是一个运行时属性。
关于变量创建和销毁的高级说明
上述关于创建、初始化和销毁的规则是保证的。也就是说,对象必须在定义点之前或不晚于定义点被创建和初始化,并且在它们定义的大括号集合结束时(或者对于函数参数来说,在函数结束时)不被提前销毁。 实际上,C++ 规范给编译器提供了很多灵活性来确定何时创建和销毁局部变量。为了优化目的,对象可能被提前创建或延迟销毁。大多数情况下,局部变量在函数被调用时被创建,并在函数退出时以相反的创建顺序被销毁。我们将在将来的课程中更详细地讨论这一点,当我们谈论调用栈时。
变量生命周期示例
这是一个稍微复杂一点的程序,展示了一个名为 x 的变量的生命周期:
#include <iostream>
void doSomething()
{
std::cout << "Hello!\n";
}
int main()
{
int x{ 0 }; // x 的生命周期从这里开始
doSomething(); // 在这个函数调用期间 x 仍然存活
return 0;
} // 在这里结束 x 的生命周期
在上面的程序中,x 的生命周期从定义点开始,到 main 函数结束。这包括在 doSomething 函数执行期间所花费的时间。
对象销毁时的行为
在大多数情况下,什么也不发生。被销毁的对象简单地变得无效。
类对象销毁的特殊情况
如果对象是一个类类型的对象,在销毁之前,会调用一个特殊的函数,称为析构函数。在许多情况下,析构函数什么也不做,在这种情况下,不会产生任何成本。我们在课程 15.4 — 析构函数简介中介绍析构函数。
在对象被销毁后使用它将导致未定义行为。 在销毁后,对象所使用的内存将被释放(释放以便重用)。
局部作用域(块作用域)的概念
一个标识符的作用域决定了在源代码中哪里可以看到和使用该标识符。当一个标识符可以看到和使用时,我们说它在作用域内。当一个标识符不可见时,我们不能使用它,我们说它不在作用域内。作用域是一个编译时属性,尝试在标识符不在作用域内时使用它将导致编译错误。 局部变量的标识符具有局部作用域。具有局部作用域的标识符(技术上称为块作用域)可以从定义点到包含标识符的最内层大括号对结束(或者对于函数参数来说,在函数结束时)。这确保了局部变量不能在定义点之前使用(即使编译器选择在之前创建它们),也不能在它们被销毁后使用。在其他被调用的函数中,一个函数中定义的局部变量也不在作用域内。
变量作用域示例
这是一个展示名为 x 的变量作用域的程序:
#include <iostream>
// 在这个函数中,x 不在任何地方都在作用域内
void doSomething()
{
std::cout << "Hello!\n";
}
int main()
{
// 因为 x 还不在作用域内,所以这里不能使用 x
int x{ 0 }; // x 在这里进入作用域,现在可以在该函数内使用
doSomething();
return 0;
} // 在这里 x 离开了作用域,不能再使用
在上面的程序中,变量 x 在定义点进入作用域。x 在包含标识符的最内层大括号对结束时离开作用域,这是 main 函数的闭合大括号。注意,变量 x 在 doSomething 函数内的任何地方都不在作用域内。在这个上下文中,函数 main 调用函数 doSomething 是不相关的。
“不在作用域内"与"离开作用域"的区别
“不在作用域内"和"离开作用域"这两个术语可能会让新程序员感到困惑。 一个标识符在任何无法在代码中访问的地方都是不在作用域内的。在上面的例子中,标识符 x 从其定义点到 main 函数的结束都在作用域内。标识符 x 在那个代码区域之外是不在作用域内的。 “离开作用域"这个术语通常应用于对象而不是标识符。我们说一个对象在它被实例化的作用域结束时(结束大括号)离开作用域。在上面的例子中,名为 x 的对象在 main 函数结束时离开作用域。
局部变量的生命周期在它离开作用域的点结束,所以局部变量在这一点被销毁。 注意,并非所有类型的变量在离开作用域时都会被销毁。我们将在将来的课程中看到这些例子。
函数调用过程中的作用域和生命周期
这是一个稍微复杂一点的例子。记住,生命周期是一个运行时属性,作用域是一个编译时属性,所以尽管我们在同一程序中讨论两者,它们在不同的点被执行。
#include <iostream>
int add(int x, int y) // 在这里创建并进入 x 和 y 的作用域
{
// 只能在 add() 内使用 x 和 y
return x + y;
} // 在这里 y 和 x 离开作用域并被销毁
int main()
{
int a{ 5 }; // 在这里创建、初始化并进入 a 的作用域
int b{ 6 }; // 在这里创建、初始化并进入 b 的作用域
// 只能在 main() 内使用 a 和 b
std::cout << add(a, b) << '\n'; // 调用 add(5, 6),其中 x=5 和 y=6
return 0;
} // 在这里 b 和 a 离开作用域并被销毁
参数 x 和 y 在 add 函数被调用时被创建,只能在函数 add 内看到/使用,并在 add 结束时被销毁。变量 a 和 b 在函数 main 内被创建,只能在函数 main 内看到/使用,并在 main 结束时被销毁。
程序执行流程详解
为了加深你对所有这些如何组合在一起的理解,让我们更详细地追踪这个程序。以下按顺序发生:
- 执行从 main 的顶部开始。
- main 变量 a 被创建并赋予值 5。
- main 变量 b 被创建并赋予值 6。
- 函数 add 被调用,参数值为 5 和 6。
- add 参数 x 和 y 被创建并分别初始化为值 5 和 6。
- 计算表达式 x + y 产生值 11。
- add 将值 11 复制回调用者 main。
- add 参数 y 和 x 被销毁。
- main 将 11 打印到控制台。
- main 返回 0 到操作系统。
- main 变量 b 和 a 被销毁。
我们完成了。 注意,如果函数 add 被调用两次,参数 x 和 y 将被创建和销毁两次 —— 每次调用一次。在有很多函数和函数调用的程序中,变量经常被创建和销毁。
函数间的变量独立性
在上面的例子中,很容易看出变量 a 和 b 与 x 和 y 是不同的变量。 现在考虑以下类似的程序:
#include <iostream>
int add(int x, int y) // add 的 x 和 y 在这里被创建并进入作用域
{
// 只能在这个函数内看到/使用 add 的 x 和 y
return x + y;
} // 在这里 add 的 y 和 x 离开作用域并被销毁
int main()
{
int x{ 5 }; // main 的 x 被创建、初始化并进入作用域
int y{ 6 }; // main 的 y 被创建、初始化并进入作用域
// 只能在这个函数内看到/使用 main 的 x 和 y
std::cout << add(x, y) << '\n'; // 用 x=5 和 y=6 调用函数 add()
return 0;
} // 在这里 main 的 y 和 x 离开作用域并被销毁
在这个例子中,我们所做的只是将函数 main 内的变量 a 和 b 的名称更改为 x 和 y。这个程序编译和运行得与之前相同,尽管 main 和 add 函数都有名为 x 和 y 的变量。为什么这样做有效? 首先,我们需要认识到,尽管 main 和 add 函数都有名为 x 和 y 的变量,但这些变量是不同的。main 函数中的 x 和 y 与 add 函数中的 x 和 y 无关 —— 它们只是碰巧有相同的名字。
其次,当在 main 函数内部时,名称 x 和 y 指的是 main 的局部作用域变量 x 和 y。这些变量只能在 main 内部看到(和使用)。类似地,当在 add 函数内部时,名称 x 和 y 指的是函数参数 x 和 y,它们只能在 add 内部看到(和使用)。 简而言之,add 和 main 都不知道对方有相同名称的变量。因为作用域不重叠,所以编译器总是清楚在任何时候引用的是哪个 x 和 y。
函数独立性的关键见解
用于函数参数或在函数体中声明的变量的作用域仅限于声明它们的函数。这意味着函数内的局部变量可以在不考虑其他函数中变量名称的情况下命名。这有助于保持函数的独立性。
我们将在下一章中更多地讨论局部作用域和其他类型的作用域。
局部变量定义的最佳实践
在现代 C++ 中,最佳实践是,函数体内的局部变量应该在它们首次使用的地方尽可能近的地方定义:
#include <iostream>
int main()
{
std::cout << "Enter an integer: ";
int x{}; // 在这里定义 x
std::cin >> x; // 并在这里使用
std::cout << "Enter another integer: ";
int y{}; // 在这里定义 y
std::cin >> y; // 并在这里使用
int sum{ x + y }; // sum 可以用预期的值初始化
std::cout << "The sum is: " << sum << '\n';
return 0;
}
在上面的例子中,每个变量都在它首次使用之前定义。不需要严格遵循这一点 —— 如果你更喜欢交换第 5 行和第 6 行,那也是可以的。
最佳实践
尽可能在首次使用时定义局部变量。
顺便说一句…
由于旧的、更原始的编译器的限制,C 语言过去要求所有局部变量都在函数的顶部定义。使用该风格的等效 C++ 程序如下所示:
#include <iostream>
int main()
{
int x{}, y{}, sum{}; // 这些是如何使用的?
std::cout << "Enter an integer: ";
std::cin >> x;
std::cout << "Enter another integer: ";
std::cin >> y;
sum = x + y;
std::cout << "The sum is: " << sum << '\n';
return 0;
}
这种风格由于几个原因而不最优:
在定义点,这些变量的预期用途并不明显。你必须扫描整个函数以确定每个变量在哪里以及如何使用。
预期的初始化值可能在函数顶部不可用(例如,我们不能将 sum 初始化为其预期值,因为我们还不知道 x 和 y 的值)。
变量的初始化器和首次使用之间可能有很多行。如果我们不记得它被初始化的值,我们将不得不滚动回函数的顶部,这是令人分心的。
这个限制在 C99 语言标准中被取消。
使用函数参数与局部变量的时机
由于函数参数和局部变量都可以在函数体内使用,新程序员有时难以理解何时应该使用它们。当调用者将作为参数传递变量的初始化值时,应使用函数参数。否则,应使用局部变量。 当你应该使用局部变量时使用函数参数会导致代码看起来像这样:
#include <iostream>
int getValueFromUser(int val) // val 是函数参数
{
std::cout << "Enter a value: ";
std::cin >> val;
return val;
}
int main()
{
int x {};
int num { getValueFromUser(x) }; // main 必须将 x 作为参数传递
std::cout << "You entered " << num << '\n';
return 0;
}
在上面的例子中,getValueFromUser() 将 val 定义为函数参数。因此,main() 必须定义 x,以便它有东西可以作为参数传递。然而,x 的实际值从未被使用,val 被初始化的值也从未被使用。让调用者定义并传递一个从未被使用的变量增加了不必要的复杂性。 正确的写法如下:
#include <iostream>
int getValueFromUser()
{
int val {}; // val 是局部变量
std::cout << "Enter a value: ";
std::cin >> val;
return val;
}
int main()
{
int num { getValueFromUser() }; // main 不需要传递任何东西
std::cout << "You entered " << num << '\n';
return 0;
}
在这个例子中,val 现在是局部变量。main() 现在更简单,因为它不需要定义或传递变量来调用 getValueFromUser()。
最佳实践
当函数内需要变量时:
如果调用者将作为参数传递变量的初始化值,则使用函数参数。
否则使用局部变量。
临时对象简介
临时对象(有时也称为匿名对象)是一个未命名的对象,用来持有只需要短时间保留的值。当需要时,编译器会生成临时对象。 创建临时值有很多不同的方式,但这里有一个常见的例子:
#include <iostream>
int getValueFromUser()
{
std::cout << "Enter an integer: ";
int input{};
std::cin >> input;
return input; // 将 input 的值返回给调用者
}
int main()
{
std::cout << getValueFromUser() << '\n'; // 返回值存储在哪里?
return 0;
}
在上面的程序中,函数 getValueFromUser() 将存储在局部变量 input 中的值返回给调用者。因为 input 将在函数结束时被销毁,所以调用者收到一个值的副本,以便即使在 input 被销毁后,它也可以使用该值。
但是,复制回调用者的值存储在哪里呢?我们还没有在 main() 中定义任何变量。答案是返回值存储在临时对象中。然后这个临时对象被传递给 std::cout 以打印。
关键见解
按值返回将返回值的副本(临时对象)返回给调用者。
临时对象根本没有作用域(这是有道理的,因为作用域是标识符的属性,而临时对象没有标识符)。
临时对象在它们被创建的完整表达式的末尾被销毁。这意味着临时对象总是在下一个语句执行之前被销毁。
在我们的上述例子中,为持有 getValueFromUser() 的返回值而创建的临时对象在 std::cout « getValueFromUser() « ‘\n’ 执行后被销毁。
如果临时对象被用来初始化变量,初始化将在临时对象被销毁之前发生。
在现代 C++(特别是自 C++17 以来),编译器有很多技巧来避免在以前需要时生成临时对象。例如,当我们使用返回值来初始化变量时,这通常会生成一个持有返回值的临时对象,然后使用该临时对象来初始化变量。然而,在现代 C++ 中,编译器通常会跳过创建临时对象,并直接用返回值初始化变量。
类似地,在上面的例子中,由于 getValueFromUser() 的返回值立即被输出,编译器可以跳过 main() 中的临时对象的创建和销毁,并使用 getValueFromUser() 的返回值直接初始化 operator« 的参数。
测验时间
以下程序打印什么?
#include <iostream>
void doIt(int x)
{
int y{ 4 };
std::cout << "doIt: x = " << x << " y = " << y << '\n';
x = 3;
std::cout << "doIt: x = " << x << " y = " << y << '\n';
}
int main()
{
int x{ 1 };
int y{ 2 };
std::cout << "main: x = " << x << " y = " << y << '\n';
doIt(x);
std::cout << "main: x = " << x << " y = " << y << '\n';
return 0;
}
main: x = 1 y = 2
doIt: x = 1 y = 4
doIt: x = 3 y = 4
main: x = 1 y = 2
这个程序中发生了什么:
- 执行从 main 的顶部开始
- main 的变量 x 被创建并初始化为值 1
- main 的变量 y 被创建并初始化为值 2
- std::cout 打印 main: x = 1 y = 2
- 用参数 1 调用 doIt
- doIt 的参数 x 被创建并初始化为值 1
- doIt 的变量 y 被创建并初始化为值 4
- doIt 打印 doIt: x = 1 y = 4
- doIt 的变量 x 被赋值为新值 3
- std::cout 打印 doIt: x = 3 y = 4
- doIt 的 y 和 x 被销毁
- std::cout 打印 main: x = 1 y = 2
- main 返回 0 到操作系统
- main 的 y 和 x 被销毁
请注意,尽管 doIt 的变量 x 和 y 被初始化或分配了与 main 的不同值,但 main 的 x 和 y 未受影响,因为它们是不同的变量。这再次证明了函数间变量的独立性原则。