未初始化变量
与某些编程语言不同,C/C++ 不会自动将大多数变量初始化为给定值(例如零)。当一个未初始化的变量被分配一个内存地址用于存储数据时,该变量的默认值就是该内存地址中已有的任何(垃圾)值!一个尚未被赋予已知值(通过初始化或赋值)的变量称为未初始化变量。
命名法
许多读者期望“已初始化”和“未初始化”是严格的反义词,但它们并非完全如此!在日常语言中,“已初始化”意味着在定义时对象被提供了初始值。“未初始化”意味着对象尚未被赋予已知值(通过任何方式,包括赋值)。因此,一个未初始化但随后被赋值的对象不再是未初始化的(因为它已被赋予已知值)。
总结一下
- 已初始化 = 在定义时对象被赋予已知值。
- 赋值 = 在定义之后对象被赋予已知值。
- 未初始化 = 对象尚未被赋予已知值。
与此相关,请考虑以下变量定义
int x;
在第 1.4 课 -- 变量赋值和初始化中,我们注意到当未提供初始化器时,变量是默认初始化的。在大多数情况下(例如本例),默认初始化不执行实际的初始化。因此,我们会说 x
是未初始化的。我们关注的是结果(对象尚未被赋予已知值),而不是过程。
题外话…
这种缺乏初始化是 C 语言继承下来的一种性能优化,那时计算机速度很慢。想象一下,你要从文件中读取 100,000 个值。在这种情况下,你可能会创建 100,000 个变量,然后用文件中的数据填充它们。
如果 C++ 在创建时将所有这些变量都用默认值初始化,这将导致 100,000 次初始化(会很慢),并且收益甚微(因为你无论如何都会覆盖这些值)。
目前,你应该始终初始化你的变量,因为这样做所付出的代价与获得的好处相比微不足道。一旦你对这门语言更熟悉了,在某些情况下你可能会为了优化目的而省略初始化。但这应该始终有选择地、有目的地进行。
使用未初始化变量的值可能会导致意想不到的结果。考虑以下这个简短的程序
#include <iostream>
int main()
{
// define an integer variable named x
int x; // this variable is uninitialized because we haven't given it a value
// print the value of x to the screen
std::cout << x << '\n'; // who knows what we'll get, because x is uninitialized
return 0;
}
在这种情况下,计算机将一些未使用的内存分配给 x。然后它会将驻留在该内存位置的值发送到 std::cout,后者将打印该值(解释为整数)。但它会打印什么值呢?答案是“天知道!”,而且答案在每次运行程序时都可能(或可能不)改变。当作者在 Visual Studio 中运行此程序时,std::cout 有一次打印了值 7177728
,下一次打印了 5277592
。你可以随意编译并运行该程序(你的电脑不会爆炸)。
警告
某些编译器,例如 Visual Studio,在使用调试构建配置时会将内存内容初始化为某个预设值。在使用发布构建配置时则不会发生这种情况。因此,如果你想自己运行上述程序,请确保你使用的是发布构建配置(有关如何操作的提醒,请参阅第 0.9 课 -- 配置你的编译器:构建配置)。
例如,如果你在 Visual Studio 调试配置中运行上述程序,它将始终打印 -858993460,因为那是 Visual Studio 在调试配置中初始化内存的值(解释为整数)。
大多数现代编译器都会尝试检测变量是否在未赋值的情况下被使用。如果它们能够检测到,通常会发出编译时警告或错误。例如,在 Visual Studio 上编译上述程序会产生以下警告:
c:\VCprojects\test\test.cpp(11) : warning C4700: 使用了未初始化的局部变量 'x'
如果你的编译器不允许你编译和运行上述程序(例如,因为它将此问题视为错误),这里有一个可能的解决方案可以解决此问题:
#include <iostream>
void doNothing(int&) // Don't worry about what & is for now, we're just using it to trick the compiler into thinking variable x is used
{
}
int main()
{
// define an integer variable named x
int x; // this variable is uninitialized
doNothing(x); // make the compiler think we're assigning a value to this variable
// print the value of x to the screen (who knows what we'll get, because x is uninitialized)
std::cout << x << '\n';
return 0;
}
使用未初始化变量是新手程序员最常犯的错误之一,不幸的是,它也可能是最难调试的错误之一(因为如果未初始化变量恰好被分配到内存中具有合理值(如 0)的位置,程序仍然可能正常运行)。
这是“始终初始化你的变量”这一最佳实践的主要原因。
未定义行为
使用未初始化变量的值是我们遇到的第一个未定义行为的例子。未定义行为(通常缩写为 UB)是执行其行为未被 C++ 语言良好定义的代码的结果。在这种情况下,C++ 语言没有任何规则来确定如果你使用一个尚未被赋予已知值的变量的值会发生什么。因此,如果你真的这样做,将导致未定义行为。
实现未定义行为的代码可能会表现出以下任何症状:
- 你的程序每次运行都会产生不同的结果。
- 你的程序始终产生相同的错误结果。
- 你的程序行为不一致(有时产生正确结果,有时不正确)。
- 你的程序看起来正常,但在程序后期产生不正确的结果。
- 你的程序崩溃,立即或稍后。
- 你的程序在某些编译器上运行,但在其他编译器上不运行。
- 你的程序运行正常,直到你更改了其他看似无关的代码。
或者,你的代码实际上可能仍然产生正确的行为。
作者注
未定义行为就像一盒巧克力。你永远不知道你会得到什么!
如果你不小心,C++ 包含许多可能导致未定义行为的情况。我们将在未来的课程中遇到它们时指出这些情况。请注意这些情况发生的位置,并确保避免它们。
规则
务必避免所有导致未定义行为的情况,例如使用未初始化变量。
作者注
我们从读者那里得到的最常见的评论之一是:“你说我不能做 X,但我还是做了,我的程序也正常运行了!为什么?”
通常有两个答案。最常见的答案是你的程序实际上表现出未定义行为,但这种未定义行为恰好产生了你想要的结果……目前是这样。明天(或者在另一个编译器或机器上)可能就不是这样了。
或者,有时编译器作者在语言要求可能过于严格时会自由发挥。例如,标准可能规定“你必须在 Y 之前做 X”,但编译器作者可能觉得这没有必要,并让 Y 即使你不先做 X 也能正常工作。这不应该影响正确编写的程序的运行,但可能会导致错误编写的程序也能正常工作。所以上述问题的另一个答案是你的编译器可能根本没有遵循标准!这种情况确实会发生。你可以通过确保关闭编译器扩展来避免大部分这种情况,如第 0.10 课 -- 配置你的编译器:编译器扩展中所述。
实现定义行为和未指定行为
特定的编译器及其附带的标准库被称为实现(因为它们是实际实现 C++ 语言的)。在某些情况下,C++ 语言标准允许实现决定语言的某些方面将如何表现,这样编译器就可以为给定平台选择一种高效的行为。由实现定义的行为称为实现定义行为。实现定义行为必须为给定的实现进行文档化并保持一致。
我们来看一个实现定义行为的简单例子:
#include <iostream>
int main()
{
std::cout << sizeof(int) << '\n'; // print how many bytes of memory an int value takes
return 0;
}
在大多数平台上,这将产生 4
,但在其他平台上可能会产生 2
。
相关内容
我们将在第 4.3 课 -- 对象大小和 sizeof 运算符中讨论 sizeof()
。
未指定行为与实现定义行为几乎相同,即行为由实现来定义,但实现不需要记录该行为。
我们通常希望避免实现定义和未指定行为,因为这意味着如果我们的程序在不同的编译器上编译(甚至在同一编译器上,如果我们更改影响实现行为的项目设置!),它可能无法按预期工作。
最佳实践
尽可能避免实现定义和未指定行为,因为它们可能导致您的程序在其他实现上出现故障。
相关内容
我们在第 6.1 课 -- 运算符优先级和结合性中展示了未指定行为的示例。
小测验时间