为什么在C99之前,声明和代码混合被禁止?

47

我最近成为了一门主要教授C语言的大学课程的助教。由于广泛的编译器支持,该课程标准化了C90.对于以前有Java经验的C语言新手来说,其中一个非常令人困惑的概念是变量声明和代码不能在块(复合语句)内混合。

这个限制在C99中终于被取消了,但我想知道:它最初存在的原因是什么?它简化了变量作用域分析吗?它允许程序员指定在程序执行的哪些点上堆栈应该为新变量增长吗?

我认为如果它绝对没有任何目的,语言设计者就不会添加这种限制。


1
这个问题在这里有一些讨论: https://dev59.com/aljUa4cB1Zd3GeqPTaY6 我不太确定为什么会这样决定,但老实说,我喜欢在作用域的开头声明所有变量。 - AusCBloke
我知道这可能是一个高级话题,但我很想学习编译器如何优化代码,以及它们何时可以和不能进行优化,以及在让编译器优化C代码时需要牢记哪些好的实践方法。一个程序员可能不知道好的实践方法,因为他不了解编译器设计,但是一门关于编译自动优化的课程可能会有很大帮助。 - jokoon
6个回答

60
在C语言的早期,可用内存和CPU资源非常有限。因此,它需要通过最小化内存要求来快速编译。
因此,C语言被设计成仅需要一个非常简单的编译器进行快速编译。这反过来导致了"single-pass compiler"的概念:编译器尽可能快地读取源文件,并将所有内容翻译成汇编代码 - 通常是在读取源文件时完成。例如:当编译器读取全局变量的定义时,会立即发出相应的代码。
这种特性在今天仍然可以看到:
- C需要对所有东西进行"前向声明"。多通道编译器可以自己预测同一文件中变量或函数的声明。 - 这反过来使得*.h文件成为必需品。 - 在编译函数时,必须尽早计算堆栈帧的布局 - 否则编译器必须对函数体执行几次扫描。
如今,没有严肃的C编译器仍然是"单通"的,因为许多重要的优化无法在一次通行中完成。更多信息可以在Wikipedia中找到。
标准机构在相当长的时间内坚持放松关于函数体方面的"单通"点。我认为,其他事情更加重要。

7
编译函数时,需要尽快计算堆栈帧的布局,但限制条件是变量必须在的开头而不一定在函数的开头声明。因此,一个单遍的C89编译器必须在进入和退出每个块时移动堆栈指针,以利用这个限制。在这种情况下,我认为它可以在代码中间声明变量时(实际上,在那一点上开始一个额外的作用域),并保持单遍操作,也可以移动堆栈指针。 - Steve Jessop
2
@A.H:那么,在这些限制下,即使是针对C89,单遍编译器也是不可能的。显然在1989年,编译器技术和容量已经存在,可以记住中间结果存储在堆栈上的位置,而不仅仅是能够推入和弹出它们。考虑到C89与任何旧技术编译器都有所不同,不能做到这一点,我真的不明白为什么他们保留了这个限制,除了因为这是每个人都习惯的风格。 - Steve Jessop
7
即使有了这个解释,我仍然不知道在一个块的开头定义变量的优点是什么。由于你可以在初始化器中放置任意表达式,编译器仍然需要能够处理与声明混合的任意代码。 - Sven Marnach
2
我已经实现了一个单遍C到汇编编译器,其中在每个块中可以在语句之后进行声明。实现方法如下:跟踪局部变量的分配,直到当前编译的函数结束,并计算变量的累积大小。显然,你需要在函数开始时知道这个大小,但那时你还没有它。但是,没有什么阻止你以这种方式生成代码:goto L1 + L2: 函数主体代码 + return + L1: stack_pointer -= size_of_locals + goto L2 - Alexey Frunze
1
@AlexeyFrunze:在这种情况下,使用类似于“dw 0xEC81,fooFuncFrame”的东西[或者任何适用于“SUB SP,nn”指令的十六进制值]。能够处理跳转的前向标签(没有这种能力,汇编器将非常无用)与能够为其他事物处理它们并没有太大的区别。 - supercat
显示剩余13条评论

9
因为一直以来都是这样做的,这使得编写编译器变得更容易,而且没有人真正考虑过其他方式。随着时间的推移,人们意识到更重要的是要让语言使用者的生活更容易,而不是编译器作者。
我认为如果完全没有目的,语言设计者就不会添加这样的限制。
不要假设语言设计者旨在限制语言。通常,这样的限制是由机缘和巧合造成的。

也许是因为 Java 在九十年代中期出现并允许了这种操作。或者是 C++?(我不确定它从何时开始允许这样做。) - starblue
C++ 最先引入了它。 - Patrick Schlüter

5

我认为对于一个非优化编译器来说,这种方式可以更容易地产生高效的代码:

int a;
int b;
int c;
...

尽管声明了3个不同的变量,但栈指针可以一次性增加,而无需优化策略(如重新排序等)。

与此相比:

int a;
foo();
int b;
bar();
int c;

要仅一次增加堆栈指针,这需要某种优化,尽管不是非常高级的优化。

此外,作为一种风格问题,第一种方法通过一次性查看所有本地变量并最终将它们作为一个整体检查,鼓励一种更有纪律的编码方式(难怪Pascal也强制执行此操作)。这提供了代码和数据之间更清晰的分离。


1
第二个例子需要什么优化?当然,你必须等到代码块结束才能知道需要增加堆栈指针的总量以分配所有变量,但这并不难。另外,你可以使用一种叫做“回填”的技术:你为栈中变量的总大小生成一个占位符,然后在编译完代码块后设置正确的值。 - Giorgio
+1:我认为风格上的原因也非常重要,很高兴你提到了它。事实上,当这个限制从 C 中移除时,我有点失望。 :-) - Giorgio
6
实际上,从风格上讲,我认为将变量声明尽可能靠近第一次使用会更加优秀,以最小化其作用域。但这是一个不同(有争议的)问题…… - sleske
2
@sleske,这不仅仅是为了最小化它们的范围:在许多情况下,这是使变量const的唯一合理方法(例如,当值取决于在初始化新变量之前执行语句时)。如果您的所有变量都在块的开头声明,您就没有机会跟踪意外更改应该是常量的内容。 - Ruslan

3
要求变量声明必须出现在复合语句的开头并没有削弱C89的表现力。任何可以在中间声明时合理使用的内容都可以通过在声明前添加大括号和将封闭块的结束大括号加倍来完成。虽然这样的要求有时可能会在源代码中添加额外的打开和关闭大括号,但这些大括号不仅仅是噪音,它们还标记了变量作用域的开始和结束。
考虑以下两个代码示例:
{ do_something_1(); { int foo; foo = something1(); if (foo) do_something_1(foo); } { int bar; bar = something2(); if (bar) do_something_2(bar); } { int boz; boz = something3(); if (boz) do_something_3(boz); } }

{ do_something_1();
int foo; foo = something1(); if (foo) do_something_1(foo);
int bar; bar = something2(); if (bar) do_something_2(bar);
int boz; boz = something3(); if (boz) do_something_3(boz); }
从运行时的角度来看,现代大多数编译器可能并不关心在执行do_something3()期间是否在语法上存在foo,因为它可以判断在该语句之前它所持有的任何值在之后都不会被使用。另一方面,鼓励程序员以会在没有优化编译器的情况下生成次优代码的方式编写声明并不是一个令人满意的概念。
此外,尽管处理涉及交错变量声明的较简单的情况并不困难(即使是20世纪70年代的编译器如果作者想要允许这样的构造也能做到),但如果包含交错声明的块还包含任何goto或case标签,则情况会更加复杂。 C的创作者们可能认为允许变量声明和其他语句混合使用会使标准过于复杂,而得不偿失。

2

噢,但你可以(在某种程度上)混合声明和代码,但是声明新变量只限于块的开头。例如,以下是有效的C89代码:

void f()
{
  int a;
  do_something();
  {
    int b = do_something_else();
  }
}

2
在C语言的早期,例如Dennis Ritchie所使用的PDP-11,计算机内存非常有限(例如64K字),因此编译器必须非常小巧,只能进行少量简单的优化。在那个时候(我曾在1986-89年间使用Sun-4/110上编写C代码),声明register变量对于编译器来说非常有用。
如今的编译器要复杂得多。例如,最近版本的GCC(4.6)有超过500万到1000万行的源代码(取决于如何测量),并且进行了很多优化,这些优化在第一批C编译器出现时并不存在。
而如今的处理器也非常不同(不能假设今天的机器就像1980年代的机器,只是快了数千倍,并且拥有数千倍的RAM和磁盘)。如今,内存层次结构非常重要:缓存未命中是处理器最常执行的操作(等待从RAM获取数据)。但是在1980年代,访问内存几乎与执行单个机器指令一样快(或者按照当前标准来说,就是一样慢)。这在今天是完全错误的:为了读取RAM模块,处理器可能需要等待数百纳秒,而对于L1缓存中的数据,它可以每纳秒执行多个指令。
因此,不要认为C语言非常接近硬件:这在1980年代是正确的,但在今天是错误的。

实际上,C语言仍然非常接近硬件。事实上,编译器的复杂性(比如你所提到的GCC)就在于确保C语言能够被翻译成尽可能接近目标硬件优化的代码。 - Ahmed Masud
4
你在这里说的内容可能很有趣,但与原帖的问题大体上无关。 - akappa
@AhmedMasud:即使Haskell被编译成二进制代码,也并不意味着它是一种裸机语言。 - akappa
你也可以说“不要认为汇编语言与硬件非常接近”。 :) - Alexey Frunze
@AlexeyFrunze 这在 ARB_fragment_program/ARB_vertex_program 中是非常正确的,例如,在许多 GPU 上,它们并不直接映射到硬件指令集。 - Ruslan

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