C语言中变量声明的位置

145

我曾经以为在C语言中,所有变量都必须在函数开头被声明。我知道在C99中,规则与C++相同,但是C89 / ANSI C的变量声明放置规则是什么?

以下代码可以成功编译 gcc -std=c89gcc -ansi:

#include <stdio.h>
int main() {
    int i;
    for (i = 0; i < 10; i++) {
        char c = (i % 95) + 32;
        printf("%i: %c\n", i, c);
        char *s;
        s = "some string";
        puts(s);
    }
    return 0;
}

cs的声明在C89/ANSI模式下难道不应该引起错误吗?


62
注意:在 ANSI C 中,变量不必在函数开头声明,而是可以在块的开头声明。因此,在您的 for 循环顶部声明 char c = ... 是完全合法的 ANSI C 写法。但是,char *s 则不行。 - Jason Coco
8个回答

174

这段代码能够成功编译,因为GCC允许使用GNU扩展来声明s,尽管这不是C89或ANSI标准的一部分。如果你想严格遵循这些标准,你必须传递-pedantic标志。

在一个{ }块的开头声明c是C89标准的一部分;这个块不一定是一个函数。


46
值得注意的是,仅有变量s的声明是从C89的角度来看一个扩展。变量c的声明在C89中是完全合法的,不需要任何扩展。 - AnT stands with Russia
8
是的,在C语言中,变量声明应该放在一个块的开头,而不是函数本身;但人们会把块和函数混淆,因为函数是块的主要例子。 - legends2k
1
我将得到39个赞的评论移动到了回答中。 - MarcH

82

在C89标准中,你必须在一个作用域块的开头声明所有变量。

因此,在for循环的作用域块的顶部声明char c是有效的。但是,声明char *s应该会出现错误。


3
很正确。您可以在任何 { ... } 的开头声明变量。 - Artelius
6
不完全正确。只有在花括号是块的一部分时才会执行此操作(如果它们是结构体或联合声明或带大括号的初始化程序的一部分则不会执行)。 - Jens
只是为了严谨起见,根据C标准,错误声明应该得到至少通知。所以在gcc中应该是一个错误或警告。也就是说,不要相信一个程序可以通过编译就代表它是符合规范的。 - jinawee
@Jens,你如何在结构体、联合体或大括号初始化器中声明新变量?这里,“块”显然代表“代码块”。 - MarcH
@MarcH,这不是Artelius所说的。他没有任何限定地说“在任何{...}的开始”。 - Jens
是的,每个人都明白他的意思,因为没有其他合理的意义。 - MarcH

48

在块的顶部分组变量声明是一个传统习惯,可能是由于旧版的原始C编译器的限制造成的。所有现代语言都建议并有时甚至强制在最新点:即它们首次初始化时声明局部变量。因为这可以消除误用随机值的风险。分离声明和初始化还可以避免您在本应使用“const”(或“final”)时没有使用。

C ++不幸地保留了旧的顶部声明方式,以便与C向后兼容(许多其他C兼容性问题之一…)。但是,C ++尝试远离它:

  • C++引用的设计甚至不允许这种块顶部分组方式。
  • 如果您分离C++本地对象的声明和初始化,则会因为无关构造函数而付出额外的代价。如果没有no-arg构造函数,则再次不允许分离两者!

C99开始朝着这个方向移动C。

如果您担心找不到局部变量的声明位置,那么这意味着您有更大的问题:包围块太长,需要拆分。

https://wiki.sei.cmu.edu/confluence/display/c/DCL19-C.+Minimize+the+scope+of+variables+and+functions


1
http://www.learncpp.com/cpp-tutorial/21-basic-addressing-and-variable-declaration/ - MarcH
请注意,强制在块的顶部声明变量可能会导致安全漏洞:http://lwn.net/Articles/443037/。 - MarcH
@supercat,我不明白你所说的“C/C++方法”是什么意思。你是指不隐式初始化自动变量的方法吗?就像我之前说的那样,隐式默认初始化往往只会隐藏错误。它防止了在编译时检测到未初始化读取的任何可能性,并防止了在运行时检测其余错误的许多可能性。由随机数据导致的崩溃比某个虚假的零更容易被检测到。 - Jo So
1
@JoSo:我不明白为什么你认为读取未初始化变量会产生任意效果,这会比读取一致值或确定性错误更容易检测到编程错误?请注意,没有保证读取未初始化存储器的行为与变量可能具有的任何位模式一致,甚至这样的程序也不能保证遵守时间和因果关系的通常法则。例如 int y; ... if (x) { printf("X was true"); y=23;} return y;... - supercat
1
@JoSo:对于指针,特别是在捕获null操作的实现中,全零比特通常是一个有用的陷阱值。此外,在明确指定变量默认为全零比特的语言中,依赖该值不是错误。编译器目前还没有过度疯狂地进行“优化”,但编译器编写者不断尝试变得更加聪明。初始化变量的编译器选项使用故意伪随机变量可能有助于识别故障,但仅仅让存储保持其最后的值有时会掩盖故障。 - supercat
显示剩余10条评论

24

从可维护性而非语法的角度来看,有至少三种看法:

  1. 在函数开始处声明所有变量,这样它们就在一个地方,你可以一目了然地看到全面的列表。

  2. 在尽可能接近首次使用它们的位置处声明所有变量,这样你就会知道每个变量被用在哪里。

  3. 在最内层的作用域块的开始处声明所有变量,这样它们将尽快超出作用域,允许编译器优化内存,并告诉你是否意外地在没有意图的地方使用它们。

我通常喜欢第一种选择,因为我发现其他选择经常迫使我在代码中搜索声明。提前定义所有变量也使得初始化和从调试器中观察变量更容易。

我有时会在较小的作用域块中声明变量,但只有在有充分理由的情况下才会这样做,而这种情况我很少遇到。例如,在 fork() 之后,声明仅在子进程中需要的变量。对我来说,这种视觉指示是它们用途的有用提醒。


32
我使用选项2或3,这样更容易找到变量,因为函数不应该太大以至于看不到变量声明。 - Jonathan Leffler
8
除非你使用70年代的编译器,否则选项3不是问题。 - edgar.holleis
17
如果你使用一个不错的集成开发环境(IDE),就不需要进行代码搜索,因为IDE应该有一个命令来帮你找到声明。(在Eclipse中是按F3键) - edgar.holleis
4
我不理解你如何确保选项1中的初始化,有时你可能只能稍后在块内调用另一个函数或执行计算来获取初始值。 - Plumenator
4
选项1不能保证初始化;我选择在声明时将它们初始化,无论是为它们的“正确”值还是为一些值,以确保后续的代码在未适当设置变量时将会出现错误。我说“选择”是因为自从写下这句话后我的偏好已经改变为选项2,可能是因为我现在更多地使用Java而不是C,并且拥有更好的开发工具。 - Adam Liss
显示剩余5条评论

8
正如其他人所指出的,GCC在这方面非常宽容(取决于调用它们的参数,可能还有其他编译器),即使处于“C89”模式下,除非您使用“严格”的检查。老实说,没有太多不开启严格检查的好理由;优秀的现代代码应该始终能够在没有警告的情况下编译通过(或者只在您知道自己正在进行特定可能被编译器视为错误的操作时出现很少量的警告),因此,如果您无法使用严格设置使代码编译通过,则可能需要对其进行一些调整。
C89要求在每个作用域内的任何其他语句之前声明变量,而后来的标准允许更接近使用的声明(这既可以更直观也可以更有效),特别是在“for”循环中同时声明和初始化循环控制变量。

3
正如已经指出的那样,有两种看法。
1)因为年份是1987年,所以在函数顶部声明所有内容。
2)尽可能靠近第一次使用并在最小范围内声明。
我的答案是:两者都要做!让我解释一下:
对于长函数,选项1会使重构变得非常困难。如果你在一个开发人员反对子程序概念的代码库中工作,那么你将在函数开始时有50个变量声明,其中一些可能只是用于位于函数底部的for循环的“i”。
因此,我从中获得了“在顶部声明PTSD”的经验,并试图虔诚地执行选项2)。
我又回到了选项1,因为有一件事情:短函数。如果你的函数足够短,那么你将有很少的局部变量,并且由于函数很短,如果你把它们放在函数顶部,它们仍然会靠近第一次使用。
此外,“在顶部声明并设置为NULL”这种反模式,当你想在顶部声明但还没有进行初始化所需的某些计算时,就会被解决,因为你需要初始化的东西可能会作为参数接收。
因此,现在我的想法是:应该在函数顶部和尽可能靠近第一次使用处声明。所以两者都要做!而实现这一点的方法是良好分割的子程序。
但如果你正在处理一个长函数,那么把东西放在最靠近第一次使用的地方,因为这样提取方法会更容易。
我的建议是:对于所有局部变量,将变量移动到底部进行声明,编译,然后将声明移到编译错误之前。这就是第一次使用。对于所有局部变量都要这样做。
int foo = 0;
<code that uses foo>

int bar = 1;
<code that uses bar>

<code that uses foo>

现在,定义一个范围块,在声明之前开始,并将其移动到程序编译时结束。
{
    int foo = 0;
    <code that uses foo>
}

int bar = 1;
<code that uses bar>

>>> First compilation error here
<code that uses foo>

这段代码无法编译,因为还有其他代码使用了foo。我们可以注意到编译器能够通过使用bar的代码,因为它不使用foo。此时,有两个选择。机械化的方法是将“}”向下移动,直到它能够编译,而另一个选择是检查代码并确定是否可以更改顺序为:

{
    int foo = 0;
    <code that uses foo>
}

<code that uses foo>

int bar = 1;
<code that uses bar>

如果可以交换顺序,那可能是您想要的,因为它会缩短临时值的寿命。另外需要注意的是,foo的值是否需要在使用它的代码块之间保留,或者在两个代码块中可以是不同的foo。例如:
int i;

for(i = 0; i < 8; ++i){
    ...
}

<some stuff>

for(i = 3; i < 32; ++i){
    ...
}

这些情况需要更多的步骤。开发人员需要分析代码以确定下一步该做什么。

但第一步是查找首次使用。您可以通过视觉方式完成,但有时候只需删除声明,尝试进行编译并将其放回到首次使用之前即可更容易实现。如果该首次使用位于if语句内,请将其放置在那里并检查是否可以编译。然后编译器将识别其他用途。尝试创建一个范围块,包括两个用法。

完成这个机械化部分后,接下来更容易分析数据所在位置。如果一个变量在大范围块中使用,请分析情况并查看是否只是针对两个不同事物使用相同的变量(例如,i在两个for循环中都使用)。如果用途无关,请为每个用途创建新变量。


深刻的思考。谢谢你分享它。 - Daniel Bandeira

0

我将引用gcc版本4.7.0手册中的一些语句,以便更清晰地解释。

“编译器可以接受多个基本标准,例如‘c90’或‘c++98’,以及这些标准的GNU方言,例如‘gnu90’或‘gnu++98’。通过指定基本标准,编译器将接受遵循该标准和使用不与之矛盾的GNU扩展的所有程序。例如,‘-std=c90’关闭了GCC的某些与ISO C90不兼容的功能,例如asm和typeof关键字,但不关闭其他在ISO C90中没有意义的GNU扩展,例如省略?:表达式的中间项。”

我认为你问题的关键点是,为什么即使使用选项“-std=c89”,gcc也不符合C89。我不知道你的gcc版本,但我认为不会有太大的区别。gcc的开发人员告诉我们,“-std=c89”选项只是表示与C89相矛盾的扩展被关闭了。因此,它与某些在C89中没有意义的扩展无关。而不限制变量声明位置的扩展属于不与C89相矛盾的扩展。

说实话,每个人都会认为选项“-std=c89”应该完全符合C89标准。但事实并非如此。 至于在开头声明所有变量是更好还是更差的问题,只是一种习惯问题。

符合标准并不意味着不能接受扩展:只要编译器能够编译有效的程序并为其他程序生成所需的任何诊断信息,它就是符合标准的。 - Remember Monica
1
@Marc Lehmann,当使用“符合”一词来区分编译器时,您是正确的。但是,当使用“符合”一词来描述某些用法时,您可以说“使用不符合标准。”而所有初学者都认为不符合标准的用法应该引起错误。 - junwanghe
顺便说一下,@Marc Lehmann,在gcc看到不符合C89标准的用法时,没有诊断。 - junwanghe
你的答案仍然是错误的,因为声称“gcc不符合”并不等同于“某些用户程序不符合”。你对conform的使用是不正确的。此外,当我还是一个初学者时,我并不持有你所说的观点,所以那也是错误的。最后,符合标准的编译器没有诊断不符合标准的代码的要求,事实上,这是不可能实现的。 - Remember Monica

-3
你应该在函数的顶部或“本地”声明所有变量。答案是:
这取决于你使用的系统类型:
1/ 嵌入式系统(特别是涉及到像飞机或汽车之类的生活): 它允许您使用动态内存(例如:calloc,malloc,new...)。想象一下,您正在开展一个非常大的项目,有1000名工程师。如果他们分配新的动态内存并忘记删除它(当它不再使用时),会发生什么?如果嵌入式系统运行时间很长,它将导致堆栈溢出和软件损坏。不容易确保质量(最好的方法是禁止使用动态内存)。
如果一架飞机运行了30天而没有关闭,如果软件被损坏(当飞机仍在空中时),会发生什么?
2/ 其他系统,如Web、PC(具有大内存空间):
您应该局部地声明变量以优化内存使用。如果这些系统长时间运行并且发生堆栈溢出(因为某人忘记删除动态内存),只需执行简单操作重置PC :P它对生命无影响。

1
我不确定这是正确的。你是说如果在一个地方声明所有局部变量,检查内存泄漏会更容易?这可能是正确的,但我不确定是否接受这种说法。至于第二点,你说将变量声明为本地变量会“优化内存使用”?这在理论上是可能的。编译器可以选择调整函数期间的堆栈帧大小以最小化内存使用,但我不知道有哪些编译器会这样做。实际上,编译器将只是将所有“局部”声明转换为“函数启动时背后的操作”。 - QuinnFreedman
嵌入式系统有时不允许动态内存分配,因此如果你在函数的顶部声明所有变量,在构建源代码时,可以计算它们在堆栈中运行程序所需的字节数。但对于动态内存分配,编译器无法做到这一点。 - Dang_Ho
2/ 如果您在本地声明一个变量,那么该变量仅存在于“{}”打开/关闭括号内。因此,如果该变量“超出范围”,编译器可以释放变量的空间。这可能比在函数顶部声明所有内容更好。 - Dang_Ho
我认为你对静态内存和动态内存有些困惑。静态内存是在堆栈上分配的。在函数中声明的所有变量,无论它们在哪里声明,都是静态分配的。动态内存是使用类似于malloc()的方法在堆上分配的。虽然我从未见过不能使用动态分配的设备,但最好在嵌入式系统中避免动态分配(请参见此处)。但这与在函数中声明变量的位置无关。 - QuinnFreedman
是的,我在谈论动态内存,如果我们在函数中间声明变量。例如: if(isHWInterrupt) int a; else char b; 当编译器编译时,它无法知道是否会发生硬件中断。所以它有两个选择:
  1. 在堆栈中为int a和char b创建空间
  2. 在运行时创建变量并存储在动态内存空间中。然后在条件结束后释放变量。 第二个选择使用动态内存。因此,如果禁用动态内存,则也禁止在函数中间声明变量。
- Dang_Ho
1
虽然我同意这是一种合理的操作方式,但实际上并不是这样的。以下是类似于您示例的实际汇编代码:https://godbolt.org/z/mLhE9a。正如您所看到的,在第11行中,“sub rsp, 1008”为整个数组分配了空间,而这是在if语句之外完成的。这对于我尝试过的每个版本和优化级别的“clang”和“gcc”都是正确的。 - QuinnFreedman

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