未定义行为

未初始化变量

变量初始化的重要性

与一些编程语言不同,C/C++不会自动将大多数变量初始化为给定值(例如零)。当一个未初始化的变量被赋予一个内存地址用来存储数据时,默认值就是那个内存地址中已经存在的(垃圾)值!一个未被赋予已知值(通过初始化或赋值)的变量被称为未初始化变量。

初始化相关术语

许多读者期望"初始化"和"未初始化"这两个术语是严格的反义词,但它们并不完全是!在通用语言中,“初始化"意味着在定义点为对象提供了一个初始值。“未初始化"意味着对象尚未被赋予已知值(通过任何方式,包括赋值)。因此,一个未初始化但随后被赋予值的对象不再是未初始化的(因为它已被赋予了已知值)。 总结如下:

初始化 = 在定义点为对象赋予已知值。

赋值 = 在定义点之外为对象赋予已知值。

未初始化 = 对象尚未被赋予已知值。

默认初始化与未初始化

相关地,考虑这个变量定义: int x; 在第1.4课——变量赋值和初始化中,我们注意到当没有提供初始化器时,变量是默认初始化的。在大多数情况下(例如这个例子),默认初始化不会执行实际的初始化。因此我们会说x是未初始化的。我们关注的是结果(对象尚未被赋予已知值),而不是过程。

缺乏初始化的历史原因

顺便说一句… 这种缺乏初始化是C语言继承的性能优化,那时计算机速度很慢。想象一下,如果你要从文件中读取100,000个值。在这种情况下,你可能会创建100,000个变量,然后用文件中的数据填充它们。 如果C++在创建时就用默认值初始化所有这些变量,这将导致100,000次初始化(这将很慢),而且几乎没有任何好处(因为你无论如何都要覆盖这些值)。 目前,你应该始终初始化你的变量,因为这样做的成本与好处相比微不足道。一旦你对语言更加熟悉,可能会有一些情况你会为了优化目的省略初始化。但这应该始终是有选择性和有意识地进行。

使用未初始化变量的危险

未初始化变量的问题

使用未初始化变量的值可能导致意外的结果。考虑以下简短程序:

#include <iostream>

int main()
{
    // 定义一个名为x的整型变量
    int x; // 这个变量是未初始化的,因为我们还没有给它赋值
    
    // 将x的值打印到屏幕上
    std::cout << x << '\n'; // 谁知道我们会得到什么,因为x是未初始化的

    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&) // 现在不用担心&是什么,我们只是用它来欺骗编译器,让它认为变量x被使用了
{
}

int main()
{
    // 定义一个名为x的整型变量
    int x; // 这个变量是未初始化的

    doNothing(x); // 让编译器认为我们正在给这个变量赋值

    // 将x的值打印到屏幕上(谁知道我们会得到什么,因为x是未初始化的)
    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'; // 打印int值占用多少字节的内存

	return 0;
}

在大多数平台上,这将产生4,但在其他平台上可能会产生2。

我们在第4.3课——对象大小和sizeof运算符中讨论sizeof()。

未指定行为

未指定行为与实现定义行为几乎相同,在于行为由实现来定义,但实现不需要记录行为。

避免实现定义和未指定行为

我们通常希望避免实现定义和未指定行为,因为这意味着我们的程序如果在不同的编译器上编译(或者即使在同一个编译器上,如果我们更改影响实现行为的项目设置!)可能无法按预期工作。

最佳实践 尽可能避免实现定义和未指定行为,因为它们可能会导致你的程序在其他实现上出现故障。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"