为什么未分配的指针会指向不可预测的内存而不是指向NULL?

45

很久以前我曾经在学校里用C语言编程,有一件事情让我非常讨厌:未初始化的指针不会默认为 NULL。

我问了很多人,包括老师,为什么一个未被赋值的指针不会默认指向 NULL?这看起来更加危险和不可预测。

答案是考虑到性能原因,但我从来没有相信过这个解释。我认为如果 C 默认指向 NULL,很多编程历史上的bug本可以避免。

以下是一些C代码以说明我所说的内容(说笑话):

#include <stdio.h>

void main() {

  int * randomA;
  int * randomB;
  int * nullA = NULL;
  int * nullB = NULL;


  printf("randomA: %p, randomB: %p, nullA: %p, nullB: %p\n\n", 
     randomA, randomB, nullA, nullB);
}

编译时出现警告(很高兴看到C编译器比我在学校时好多了),并输出:

randomA: 0xb779eff4,randomB: 0x804844b,nullA: (nil),nullB: (nil)


12
C 说:相信程序员。程序员学会了追踪他们的变量。 - u0b34a0f6ae
6
我认为你混淆了C语言和高级语言,但它并不是高级语言。 - Skilldrick
4
只有当你的代码访问未初始化变量的值时,这种区别才会有所影响,但是你的代码永远不应该这样做。现代大多数C编译器会因此而报错,这是有充分理由的。 - eemz
5
ТїЄжњѕуџёТГБуА«printfТа╝т╝ЈУ»┤ТўјугдТў»%p№╝їУђїСИЇТў»%dсђѓ - tomlogic
1
默认行为是让程序员在他们认为合适的时间和方式下初始化变量。在函数开始时,将指针初始化为NULL,然后再给它们赋予其他值是没有意义的。 - tomlogic
显示剩余9条评论
11个回答

42

实际上,这取决于指针的存储方式。静态存储的指针被初始化为null指针。自动存储期限的指针没有被初始化。参见ISO C 99 6.7.8.10:

  

如果具有自动存储期限的对象没有明确初始化,则其值未确定。如果具有静态存储期限的对象没有明确初始化,则:

  
        
  • 如果它具有指针类型,则初始化为null指针;
  •     
  • 如果它具有算术类型,则初始化为(正或无符号)零;
  •     
  • 如果它是一个聚合体,则每个成员都根据这些规则递归地初始化;
  •     
  • 如果它是联合体,则第一个命名成员根据这些规则递归地初始化。
  •   

是的,由于性能原因,具有自动存储期限的对象不被初始化。想象一下在每次调用日志函数时初始化4K数组(我在一个项目中看到过这种情况,幸运的是C让我避免了初始化,从而获得了良好的性能提升)。


当然,初始化一个4K数组不可能很慢,因为诸如Java之类的语言经常这样做(为所有引用进行初始化)。你一定有一个非常高性能的项目。 - Adam Gent
5
是的,在那个项目中,性能很重要。再加上事实上99.99%的时间,日志记录功能只是将其参数与共享内存中的某些标志进行检查,并查看是否已禁用日志记录并返回。当我发现初始化在cachegrind概要文件的前5个位置之一时,想象一下我的表情。 - ninjalj

26

因为在C语言中,声明和初始化是故意不同的步骤。它们之间有意地不同,因为这是C语言的设计。

当您在函数内部说出以下内容时:

void demo(void)
{
    int *param;
    ...
}
你在告诉C编译器,“亲爱的编译器,请在为这个函数创建堆栈帧时记得保留sizeof(int*)字节用于存储指针。”编译器不会询问那里要存什么,它认为你很快就会告诉它。如果你没有这样做,也许有更好的语言适合你;)也许生成一些安全的堆栈清除代码并不是非常困难。但是它必须在每次函数调用时被调用,我怀疑许多C开发人员并不喜欢这种额外开销,他们只想自己填充。顺便说一下,如果允许灵活使用堆栈,可以做很多提高性能的工作。例如,编译器可以进行优化,使得如果你的function1调用另一个function2并存储其返回值,或者可能有一些传入function2的参数在function2内部不会改变...我们不需要创建额外的空间,对吧?只需同时使用堆栈的相同部分即可!请注意,这与每次使用之前初始化堆栈的概念直接冲突。

但从更广义上讲,(在我看来更重要的是)它符合C的哲学,即不做比绝对必要的事情更多。这适用于无论您是在PDP11,PIC32MX(我用它来干什么)或Cray XT3上工作。这正是人们选择使用C而不是其他语言的原因。

  • 如果我想写一个没有mallocfree痕迹的程序,我就可以!没有强制内存管理!
  • 如果我想位压缩和类型转换数据结构,我就可以!(当然,只要阅读我的实现的标准遵从说明。)
  • 如果我对我的堆栈帧非常了解,那么编译器就不需要为我做任何其他事情!

简而言之,当你要求C编译器跳跃时,它不会问有多高。生成的代码可能甚至不会再返回。

由于大多数选择使用C开发的人都喜欢这种方式,因此它具有足够的惯性不进行改变。也许你的方法并不是本质上的坏主意,只是很少有其他C开发人员提出这样的需求。


3
批评一门语言因为它不是你喜欢的那种语言是毫无意义的,不是吗?C语言被广泛用于嵌入式编程的原因是它很高效。如果你要销售数百万个系统,让工程师编写安全代码比每个系统多花费一美元购买更快的CPU或更大的程序存储空间要便宜得多。 - David Thornley
2
@Adam Gent - 我想在我的高精度科学仪器中严格控制时间和内存 :) 这是安全的,因为我始终清楚我的代码正在做什么 - 我告诉它要做什么。你对安全的定义可能有所不同。(此外,请给我找一个适用于PIC32MX系列或dsPIC24的Haskell编译器。) - detly
2
@detly 您说得对,我不是有意冒犯。但是如果那是真的,为什么不用汇编语言编程呢?现在已经是2010年了,芯片变得非常便宜了吧?我们何时能停止使用C语言编程,或者您认为C语言就是那么卓越吗?难道我们现在不是正在向更多并发架构移动吗?似乎C语言在这方面比较薄弱。 - Adam Gent
2
@NoMoreZealots 和 @detly,我同意 C 语言非常贴近实际运作的方式,我很高兴学习了这门语言,因为它帮助我在学校理解了 CPU 架构。 - Adam Gent
2
@Adam Gent:有趣的是,除了可以轻松抽象的IO问题之外,CPU密集型应用程序都需要并发编程。将C应用于那些据称在高级语言中更容易的领域,大多数情况下消除了对并发编程的需求,因为与其他语言的实现相比具有极端的性能优势。如果这仍然不够,您还需要使用带有并发实现的C。切换到C:50倍的性能。切换到并发:核心计数性能增加次线性。 - Matt Joiner
显示剩余11条评论

14

这是为了提高性能。

C语言最初的开发大约是在PDP 11时代,这个时候60k的内存是常见的最大容量,许多设备可能只有更少的内存。在这种环境下,不必要的赋值操作会变得特别昂贵。

如今有很多使用C语言的嵌入式设备,对于这些设备来说,拥有60k内存似乎已经是无限的了,比如PIC 12F675仅有1k的内存。


12
@Adam:这个值之前就已经存在了。只是对一个特定内存位置的重新使用。 - tur1ng
1
不需要提起嵌入式设备,只需记住C语言是为一台计算机设计的,该计算机同时允许64K代码和64K数据。不同时期、不同限制、不同决策。 - AProgrammer
4
运行时不会指定任何内容,只是重复使用已经存在的内容。 - AProgrammer
1
@Adam,顺便说一下,我通常使用的编译器能够警告未初始化变量的使用。增加警告级别并修复发现的问题。另外,警告和编译时选项不在标准范围内,如果你需要它们但没有它们,只需游说你的编译器供应商即可。 - AProgrammer
如果一种语言的语义规定声明变量会导致它具有默认值(如C语言中的静态变量),那么在没有明确写入变量的情况下读取变量的代码可能不会出错。在某些平台上,“static int foo = 0;”和“static int foo;”可能会以不同的方式分配“foo”;如果需要后者的分配风格,即使“foo”需要在启动时为零,将其保留“未初始化”可能是正确的(也是必需的)做法。 - supercat
显示剩余9条评论

9
这是因为当你声明一个指针时,你的C编译器只会保留必要的空间来放置它。所以当你运行程序时,这个空间可能已经有一个值,可能是由于先前在内存的这部分分配了数据而导致的。
C编译器可以为这个指针分配一个值,但在大多数情况下这将是浪费时间的,因为你期望在代码的某个部分自己分配一个自定义值。
这就是为什么好的编译器在你不初始化变量时会发出警告的原因;所以我不认为有太多的bug是由于这种行为引起的。你只需要阅读警告即可。

我相信他历史上是指“很多错误”; 显然,早期的C编译器不像现代的那样友好。 - Kenny Evitt
1
在我看来,任何试图访问未初始化变量的值都是一个错误。无论它是零还是随机的,如果你没有明确地将其设置为某些值,就不应该尝试读取它。 - eemz
@joefis 但如果它是NULL而不是一些随机值,那么查找和理解错误会更容易。这在进行并发编程时特别有用。 - Adam Gent
1
@Adam Gent:嗯,这取决于你的观点。就我个人而言,我认为垃圾值更清楚地表明了我忘记设置变量,而 NULL 可能是我故意这样做的... - eemz
@AdamGent 程序员有责任在使用前初始化变量的值,编译器已经有了相关警告。将其设置为NULL可能并不能更容易地找到错误。MSVC和许多其他编译器已经在调试模式下用[0xCC、0xCD](https://dev59.com/ZXRC5IYBdhLWcg3wOeWB)或其他一些值填充未初始化的内存,这比0更容易识别。你会看到一些关于0xCCCCCCCC的参考,一些奇怪的重复字符或很多类似的情况。 - phuclv

8
指针在这方面并不特殊;如果您未初始化其他类型的变量,则会出现完全相同的问题:
int a;
double b;

printf("%d, %f\n", a, b);

原因很简单:要求运行时将未初始化的值设置为已知值会增加每个函数调用的开销。这种开销可能在单个值上不大,但考虑一下如果你有一个大的指针数组:
int *a[20000];

由于C语言的过程性质,您经常在分配变量之前定义它们。因此,我可以看出这会成为一个性能问题。 - Adam Gent

4
当你在函数开头声明一个(指针)变量时,编译器将执行两种操作中的一种:为该变量设置一个寄存器,或者在栈上分配空间。对于大多数处理器来说,在栈中为所有本地变量分配内存的操作都可以使用一条指令完成;它会计算出所有本地变量需要的内存量,并将栈指针下移(或在某些处理器上上移)相应大小的空间。除非您显式更改,否则此时该内存中已经存在的内容不会被更改。
指针并不会被“设置”为“随机”值。在分配之前,栈指针(SP)下方的栈内存包含以前使用的任何内容:
         .
         .
 SP ---> 45
         ff
         04
         f9
         44
         23
         01
         40
         . 
         .
         .

在为本地指针分配内存后,唯一改变的是堆栈指针:

         .
         .
         45
         ff |
         04 | allocated memory for pointer.
         f9 |
 SP ---> 44 |
         23
         01
         40
         . 
         .
         .

这使编译器可以通过一条指令分配所有本地变量,将堆栈指针向下移动(并通过将堆栈指针向上移动一条指令释放它们),但如果需要初始化它们,则必须自己进行初始化。

在C99中,您可以混合代码和声明,因此可以将声明推迟到能够初始化它的时候。这将允许您避免将其设置为NULL。


3
首先,强制初始化并不能修复错误,它只是掩盖了错误。在应用程序中使用没有有效值的变量(有效值因应用程序而异)本身就是一个错误。
其次,你通常可以进行自己的初始化。不要写成int *p;,而是写成int *p = NULL;或int *p = 0;。使用calloc()(将内存初始化为零)而不是malloc()(不会这样做)。 (不,所有位都是零并不一定意味着空指针或浮点值为零。是的,在大多数现代实现中确实如此。)
第三,C(和C ++)的哲学是给你快速执行任务的手段。假设你可以在语言中选择实现安全执行任务的方法和快速执行任务的方法。通过在其周围添加更多代码无法使安全的方法更快,但你可以通过这样做使快速的方法更加安全。此外,有时候你可以通过确保操作将在不需要额外检查的情况下安全执行来使操作既快速又安全,前提是当然你已经具备了快速选项。
C最初是设计用于编写操作系统及其相关代码的,并且操作系统的某些部分必须尽可能快。这在C中是可能的,但在更安全的语言中则不太可能。此外,C是在最大的计算机比我口袋里的电话还要弱小的时候开发出来的(我很快就要升级它,因为它感觉又老又慢)。在经常使用的代码中节省几个机器周期可能会产生明显的效果。

1

总而言之,按照ninjalj的解释,如果您稍微更改一下示例程序,您的指针实际初始化为NULL:

#include <stdio.h>

// Change the "storage" of the pointer-variables from "stack" to "bss"  
int * randomA;
int * randomB;

void main() 
{
  int * nullA = NULL;
  int * nullB = NULL;

  printf("randomA: %p, randomB: %p, nullA: %p, nullB: %p\n\n", 
     randomA, randomB, nullA, nullB);
}

在我的电脑上,这将打印:
randomA: 00000000,randomB: 00000000,nullA: 00000000,nullB: 00000000

有趣的是,我的 C 运行时似乎输出 nil() 而不是 00000000。 您使用的是什么操作系统/编译器? - Adam Gent
MSYS / MinGW,GCC 版本 3.4.2(mingw-special) - S.C. Madsen

0
我认为这是由以下原因引起的:没有理由让内存包含特定值(0、NULL或其他)。因此,如果之前没有明确写入,内存位置可以包含任何值,从你的角度来看,它仍然是随机的(但是该位置以前可能已被某些其他软件使用,并且因此包含对该应用程序有意义的值,例如计数器,但从“你”的角度来看,它只是一个随机数)。 要将其初始化为特定值,您至少需要一条指令;但是有些情况下,您不需要此初始化,例如v = malloc(x)将分配给v一个有效地址或NULL,无论v的初始内容如何。因此,将其初始化可能被认为是浪费时间,语言(如C)可以选择不在a priori时进行初始化。 当然,现在这主要是微不足道的,有些语言未初始化的变量具有默认值(指针为空时为null;数字为0/0.0等),懒惰初始化当然使初始化1百万个元素的数组不那么昂贵,因为只有在赋值之前访问才会真正初始化它们。

0

除了嵌入式系统外,这个想法与机器开机时的随机内存内容有关是错误的。任何具有虚拟内存和多进程/多用户操作系统的机器都会在将其提供给进程之前初始化内存(通常为0)。不这样做将是一个重大的安全漏洞。自动存储变量中的“随机”值来自同一进程先前使用堆栈的情况。同样,malloc/new等返回的内存中的“随机”值来自同一进程中先前分配(然后释放)的内存。


他认为这与随机内存内容无关,他只是在他的示例中使用变量名randomX来指出未初始化的指针似乎被初始化为“随机”地址。 - b00n12

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接