C语言有哪些鲜为人知的特性?及C语言未定义行为一览

本文摘自Quora热门问答,探讨了C语言中一些不常见和可能产生未定义行为的特性,如声明紧随用途、指定初始化、受限指针、静态数组索引、泛型表达式等。同时,文章通过实例展示了C语言中的数组、指针、运算符等使用的细节,强调了理解这些特性对于避免未定义行为的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

译注:本文摘编自 Quora 的一个热门问答贴。 请在linux系统下测试本文中出现的代码

Andrew Weimholt 的回复:

switch语句中的case 关键词可以放在if-else或者是循环当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
switch (a)
{
     case 1:;
       // ...
       if (b==2)
       {
         case 2:;
         // ...
       }
       else case 3:
       {
         // ...
         for (b=0;b<10;b++)
         {
           case 5:;
           // ...
         }
       }
       break ;
 
     case 4:

Brian Bi 的回复:

1. 声明紧随用途之后

理解声明有一条很简单的法则,不过不是什么“从左向右”这种没道理却到处宣传的法则。这一法则的观点是,一个声明是要告诉你,你所声明的对象要如何使用。例如:

1
2
3
4
int *p; /* *p是int类型的, 因此p是指向int类型的指针 */
int a[5]; /* a[0], ..., a[4] 是int类型的, 因此a是int类型的数组 */
int *ap[5]; /* *ap[0], .., *ap[4] 是int类型的, 因此ap是包含指向int类型指针的指针数组 */
int (*pa)[5]; /* (*pa)[0], ..., (*pa)[4] 是int类型的, 因此pa是指向一个int类型数组的指针 */

更多详情请看这里: Brian Bi’s answer to C (programming language): Why doesn’t C use better notation for pointers?

2. 指定初始化:

在C99之前,你只能按顺序初始化一个结构体。在C99中你可以这样做:

1
2
3
4
5
6
struct Foo {
     int x;
     int y;
     int z;
};
Foo foo = {.z = 3, .x = 5};

这段代码首先初始化了foo.z,然后初始化了foo.xfoo.y 没有被初始化,所以被置为0。
这一语法同样可以被用在数组中。以下三行代码是等价的:

1
2
3
int a[5] = {[1] = 2, [4] = 5};
int a[] = {[1] = 2, [4] = 5};
int a[5] = {0, 2, 0, 0, 5};
3. 受限指针(C99):

restrict关键词是一个限定词,可以被用在指针上。它向编译器保证,在这个指针的生命周期内,任何通过该指针访问的内存,都只能被这个指针改变。比如,在

1
2
3
4
5
6
int f( const int * restrict x, int * y) {
     (*y)++;
     int z = *x;
     (*y)--;
     return z;
}

编译器可能会假设,xy 所指的并不是同一个int对象,因为如果它们指向了同一个对象,则x的值将可以通过y修改,这正是你保证不会发生的。因此,将允许编译器来优化f,就好像函数原本被写做如下这样:

1
2
3
int f( const int * restrict x, int * y) {
     return *x;
}

如果你违反协议向f传递两个指向同一int对象的指针时,将产生未定义行为。

我猜想,引入这一特性最初的动机之一是想让C语言在数值计算时可以Fortran一样快。在Fortran 中,默认假定数组不会重叠,因此只有你通过restrict 限定词来显式的告诉编译器数组不能重叠,编译器才能在C语言中进行这样的优化。

4. 静态数组索引(C99)

1
2
3
void f( int a[ static 10]) {
     /* ... */
}

中,你向编译器保证,你传递给f 的指针指向一个具有至少10个int 类型元素的数组的首个元素。我猜这也是为了优化;例如,编译器将会假定a 非空。编译器还会在你尝试要将一个可以被静态确定为null的指针传入或是一个数组太小的时候发出警告。

1
2
3
void f( int a[ const ]) {
     /* ... */
}

你不能修改指针a.,这和说明符int * const a.作用是一样的。然而,当你结合上一段中提到的static 使用,比如在int a[static const 10] 中,你可以获得一些使用指针风格无法得到的东西。

5. 泛型表达式(C11)

这个表达式会在编译期间根据控制表达式的类型,在一个含有一个或多个备选方案的集合中做出选择。下面这个例子可以很好的说明这一切:

1
2
3
4
5
#define cbrt(X) _Generic((X), \
                         long double : cbrtl, \
                         default : cbrt, \
                         float : cbrtf \
                         )(X)

因此,如果expr 是long double类型的, cbrt(expr) 被转换为cbrtl(expr),如果是float类型 则转换为cbrtf(expr) ,或是转换为cbrt(expr),如果是其他不同的类型(比如说double )。注意,_Generic 可以用在宏以外的地方,但是用在宏里面最好因为C不允许你进行函数重载。

6. wint_t (C99)

我相信大家都知道wint_t 但是 wint_t 到底是个什么鬼东西呢?

好吧,记住fgetc 实际上并不会返回 char 。它会返回int。显然这是因为fgetc 必须返回返回一个与其他char 都不同的值,也就是EOF,表示到达文件末尾。基于相同的原因,fgetwc 并不返回wchar_t。它会返回一个类型,叫做wint_t 可以表示所有无效wchar_t 类型,包括WEOF,来表示到达文件末尾。

Michal Forišek

下面这段C程序可以准确的打印2的747次方而不产生误差。这是为什么呢?

程序:

1
2
3
4
5
6
#include <stdio.h>
#include <math.h>
int main() {
     printf ( "%.0f\n" , pow (2,747));
     return 0;
}

输出结果:

1
740298315191606967520227188330889966610377319868419938630605715764070011466206019559325413145373572325939050053182159998975553533608824916574615132828322000124194610605645134711392062011527273571616649243219599128195212771328

答案:

这个问题包含两个部分。
其一,2的次方可以在double 中被准确的保存而不产生任何精度上的损失(这一结论直到2^1023都是对的,再往后就会产生上溢,得到一个正无穷的值)

另外一部分,很多人猜测是语言实现中的某些特殊情况导致的,但是实际上并非如此。的确,当输入的数据可以被2的某高次方整除时,有一部分代码被执行了,但是本质上这只是通常实现工作时的一个副作用。基本上,printf 在打印数字(任何类型)的时候只是做了从二进制到十进制的转换。并且由于结果对于浮点数可能会过大,printf 的内部实现包含和使用一个大整型实现,尽管在C中并没有大整型这种变量(在gcc源代码中,vfprintf.c 和dtoa.c 中包含了很多转换,如果你想要了解可以一看。)

如果你尝试打印3^474,

程序:

1
2
3
4
5
6
#include <stdio.h>
#include <math.h>
int main() {
     printf ( "%.0f\n" , pow (3,474));
     return 0;
}

输出结果:

14304567688284661153278974752312031583901259203711201647725006924333106634519194823303091330277684776547167093155518867557708479462413116497799842448027156309852771422896137582164841870381535840058702788340257784498862132559872

结果仍然是一个很大的数且位数也正确,但是这一次却不够精确。这里会产生一个相对误差,因为3^474不能以双精度浮点数准确的表示。准确的数应该是这样的143045676882846603471

译注:在linux系统上是可以的,在windows 64位上后面会有很多0

Utkal Sinha

我发现一些C语言特性或者是小技巧,我觉得只有很少的人知道。

1. 不使用加号来使数字相加

因为printf() 函数返回它所打印的字符的个数,我们可以利用这一点来使数字相加,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<stdio.h>;
 
int add( int a, int b){
     if ( if (a!=0&&b!=0))
         return printf ( "%*c%*c" ,a, '\r' ,b, '\r' );
     else return a!=0?a:b;
}
 
int main(){
     int A = 0, B = 0;
     printf ( "Enter the two numbers to add\n" );
     scanf ( "%d %d" ,&A,&B);
     printf ( "Required sum is %d" ,add(A,B));
 
     return 0;
}

利用位操作同样也可以做到:

1
2
3
4
5
6
7
int Add( int x, int y)
{
     if (y == 0)
         return x;
     else
         return Add( x ^ y, (x & y) << 1);
}
2. 条件运算符的用法

通常我们都这样使用它:
x = (y < 0) ? 10 : 20;
但是同样也可以这样用:
(y < 0 ? x : y) = 20;

3. 在一个返回值为void 的函数中写一个return 语句
1
2
3
4
5
6
7
8
9
static void foo ( void ) { }
static void bar ( void ) {
return foo(); // 注意这里的返回语句.
}
 
int main ( void ) {
bar();
return 0;
}
4. 逗号表达式的使用

通常逗号表达式会这样使用:

1
2
3
4
for ( int i=0; i<10; i++, doSomethingElse())
{
   /* whatever */
}

但是你可以在其他任何地方使用逗号表达式:

1
int j = ( printf ( "Assigning variable j\n" ), getValueFromSomewhere());

每条语句都进行了求值,但是表达式的值是最后一个语句的值。

5. 将结构体初始化为0

struct mystruct a = {0};

这将把结构体中全部元素初始化为0

6. 多字符常量

int x = 'ABCD';

这会把x的值设置为0×41424344(或者0×44434241,取决于架构)

7. printf 允许你使用变量来格式化格式说明符本身
1
2
3
4
5
6
7
8
#include <stdio.h>
 
int main() {
     int a = 3;
     float b = 6.412355;
     printf ( "%.*f\n" ,a,b);
     return 0;
}

* 符号可以达到这一目的

希望这些可以帮助到大家
此致敬礼

Vivek Nagarajan

你可以在奇怪的地方使用#include

如果你写:

1
2
3
4
5
6
7
#include <stdio.h>
 
void main()
{
     printf
#include "fragment.c"       
}

fragment.c 包含:

1
( "dayum!\n" );

这完全没有问题。只要#include 包含完整可解析的C表达式,预处理器并不在意它放在什么位置。

Vipul Mehta

1. printf 格式限定符中指定(POSIX扩展语法)

printf("%4$d %3$d %2$d %1$d", 1, 2, 3, 9); //将会打印9 3 2 1

2. 在scanf 中忽略输入输入

scanf("%*d%d", &a);// 如果输入1 2,则只会得到2

3. 在switch 中使用范围(gcc扩展语法)
1
2
3
4
5
switch (c) {
   case 'A' ... 'Z' : //do something
   break ;
   case 1 ... 5 : //do something
}
4. 使用前缀ob 来限定常数,使其被当做二进制数(gcc扩展语法)
1
printf ( "%d" ,0b1101); // prints 13
5.完全正确的最短的C语言程序
1
main;

译注:虽然编译没有error但是却不能执行

Karan Bansal

scanf()的力量

假定我们有一个数组char a[100]
读取一个字符串:
scanf("%[^\n]\n", a);//表示一直读取直到遇到'\n',并且忽略掉'\n'

读取字符串直到遇到逗号:
scanf("%[^,]", a);//但是这次不会忽略逗号

如果你想忽略掉某个输入,使用在% 后使用*,如果你想要得到John Smith 的姓:

1
scanf ( "%s %s" , temp, last_name); //典型答案,使用一个临时变量
1
2
3
scanf ( "%s" , last_name);
scanf ( "%s" , last_name);
// 另一种答案,使用一个变量但是调用两次 `scanf()`
1
2
scanf ( "%*s %s" , last);
//最佳答案,因为你不需要额外的变量或是调用两次`scanf()`

顺便提一句,你应该非常小心的使用scanf 因为它可能会是你的输入缓冲溢出!通常你应该使用fgets 和sscanf 而不是仅仅使用scanf,使用fgets 来读取一行,然后用sscanf 来解析这一行,就像上面演示的一样。

Afif Ahmed

~-n 等于n-1
-~n 等于n+1

原因:
当我们写-n时,实际上是以补码形式储存,所以-n 可以写成~n + 1,吧整个式子放在上面表达式的前面你就能明白原因了。

几周前,我的一位同事带着一个编程问题来到我桌前。最近我们一直在互相考问C语言的知识,所以我微笑着鼓起勇气面对无疑即将到来的地狱。

他在白板上写了几行代码,并问这个程序会输出什么?

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
 
int main(){
     int i = 0;
     int a[] = {10,20,30};
 
     int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
     printf ( "%d\n" , r);
     return 0;
}

看上去相当简单明了。我解释了操作符的优先顺序——后缀操作比乘法先计算、乘法比加法先计算,并且乘法和加法的结合性都是从左到右,于是我抓出运算符号并开始写出算式。

1
2
3
4
int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
//    =    a[0]    + 2 * a[1]  + 3 * a[2];
//    =     10     +     40    +    90;
//    = 140

我自鸣得意地写下答案后,我的同事回应了一个简单的“不”。我想了几分钟后,还是被难住了。我不太记得后缀操作符的结合顺序了。此外,我知道那个顺序甚至不会改变这里的值计算的顺序,因为结合规则只会应用于同级的操作符之间。但我想到了应该根据后缀操作符都从右到左求值的规则,尝试算一遍这条算式。看上去相当简单明了。

1
2
3
4
int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
//    =    a[2]    + 2 * a[1]  + 3 * a[0];
//    =     30     +     40    +    30;
//    = 100

我的同事再一次回答说,答案仍是错的。这时候我只好认输了,问他答案是什么。这段短小的样例代码原来是从他写过的更大的代码段里删减出来的。为了验证他的问题,他编译并且运行了那个更大的代码样例,但是惊奇地发现那段代码没有按照他预想的运行。他删减了不需要的步骤后得到了上面的样例代码,用gcc 4.7.3编译了这段样例代码,结果输出了令人吃惊的结果:“60”。

这时我被迷住了。我记得,C语言里,函数参数的计算求值顺序是未定义的,所以我们以为后缀操作符只是遵照某个随机的、而非从左至右的顺序,计算的。我们仍然确信后缀比加法和乘法拥有更高的操作优先级,所以很快证明我们自己,不存在我们可以计算i++的顺序,使得这三个数组元素一起加起来、乘起来得到60。

现在我已对此入迷了。我的第一个想法是,查看这段代码的反汇编代码,然后尝试查出它实际上发生了什么。我用调试符号(debugging symbols)编译了这段样例代码,用了objdump后很快得到了带注释的x86_64反汇编代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Disassembly of section .text:
 
0000000000000000 <main>:
#include <stdio.h>
 
int main(){
    0:   55                      push   %rbp
    1:   48 89 e5                mov    %rsp,%rbp
    4:   48 83 ec 20             sub    $0x20,%rsp
     int i = 0;
    8:   c7 45 e8 00 00 00 00    movl   $0x0,-0x18(%rbp)
     int a[] = {10,20,30};
    f:   c7 45 f0 0a 00 00 00    movl   $0xa,-0x10(%rbp)
   16:   c7 45 f4 14 00 00 00    movl   $0x14,-0xc(%rbp)
   1d:   c7 45 f8 1e 00 00 00    movl   $0x1e,-0x8(%rbp)
     int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
   24:   8b 45 e8                mov    -0x18(%rbp),%eax
   27:   48 98                   cltq 
   29:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx
   2d:   8b 45 e8                mov    -0x18(%rbp),%eax
   30:   48 98                   cltq 
   32:   8b 44 85 f0             mov    -0x10(%rbp,%rax,4),%eax
   36:   01 c0                   add    %eax,%eax
   38:   8d 0c 02                lea    (%rdx,%rax,1),%ecx
   3b:   8b 45 e8                mov    -0x18(%rbp),%eax
   3e:   48 98                   cltq 
   40:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx
   44:   89 d0                   mov    %edx,%eax
   46:   01 c0                   add    %eax,%eax
   48:   01 d0                   add    %edx,%eax
   4a:   01 c8                   add    %ecx,%eax
   4c:   89 45 ec                mov    %eax,-0x14(%rbp)
   4f:   83 45 e8 01             addl   $0x1,-0x18(%rbp)
   53:   83 45 e8 01             addl   $0x1,-0x18(%rbp)
   57:   83 45 e8 01             addl   $0x1,-0x18(%rbp)
     printf ( "%d\n" , r);
   5b:   8b 45 ec                mov    -0x14(%rbp),%eax
   5e:   89 c6                   mov    %eax,%esi
   60:   bf 00 00 00 00          mov    $0x0,%edi
   65:   b8 00 00 00 00          mov    $0x0,%eax
   6a:   e8 00 00 00 00          callq  6f <main+0x6f>
     return 0;
   6f:   b8 00 00 00 00          mov    $0x0,%eax
}
   74:   c9                      leaveq
   75:   c3                      retq

最先和最后的几个指令只建立了堆栈结构,初始化变量的值,调用printf函数,还从main函数返回。所以我们实际上只需要关心从0×24到0×57之间的指令。那是令人关注的行为发生的地方。让我们每次查看几个指令。

1
2
3
24:   8b 45 e8                mov    -0x18(%rbp),%eax
27:   48 98                   cltq 
29:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx

最先的三个指令与我们预期的一致。首先,它把i(0)的值加载到eax寄存器,带符号扩展到64位,然后加载a[0]到edx寄存器。这里的乘以1的运算(1*)显然被编译器优化后去除了,但是一切看起来都正常。接下来的几个指令开始时也大致相同。

1
2
3
4
5
2d:   8b 45 e8                mov    -0x18(%rbp),%eax
30:   48 98                   cltq 
32:   8b 44 85 f0             mov    -0x10(%rbp,%rax,4),%eax
36:   01 c0                   add    %eax,%eax
38:   8d 0c 02                lea    (%rdx,%rax,1),%ecx

第一个mov指令把i的值(仍然是0)加载进eax寄存器,带符号扩展到64位,然后加载a[0]进eax寄存器。有意思的事情发生了——我们再次期待i++在这三条指令之前已经运行过了,但也许最后两条指令会用某种汇编的魔法来得到预期的结果(2*a[1])。这两条指令把eax寄存器的值自加了一次,实际上执行了2*a[0]的操作,然后把结果加到前面的计算结果上,并存进ecx寄存器。此时指令已经求得了a[0] + 2 * a[0]的值。事情开始看起来有一些奇怪了,然而再一次,也许某个编译器魔法在发生。

1
2
3
4
3b:   8b 45 e8                mov    -0x18(%rbp),%eax
3e:   48 98                   cltq 
40:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx
44:   89 d0                   mov    %edx,%eax

接下来这些指令开始看上去相当熟悉。他们加载i的值(仍然是0),带符号扩展至64位,加载a[0]到edx寄存器,然后拷贝edx里的值到eax。嗯,好吧,让我们在多看一些:

1
2
3
4
46:   01 c0                   add    %eax,%eax
48:   01 d0                   add    %edx,%eax
4a:   01 c8                   add    %ecx,%eax
4c:   89 45 ec                mov    %eax,-0x14(%rbp)

在这里把a[0]自加了3次,再加上之前的计算结果,然后存入到变量“r”。现在不可思议的事情——我们的变量r现在包含了a[0] + 2 * a[0] + 3 * a[0]。足够肯定的是,那就是程序的输出:“60”。但是那些后缀操作符上发生了什么?他们都在最后:

1
2
3
4f:   83 45 e8 01             addl   $0x1,-0x18(%rbp)
53:   83 45 e8 01             addl   $0x1,-0x18(%rbp)
57:   83 45 e8 01             addl   $0x1,-0x18(%rbp)

看上去我们编译版本的代码完全错了!为什么后缀操作符被扔到最底下、所有任务已经完成之后?随着我对现实的信仰减少,我决定直接去找本源。不,不是编译器的源代码——那只是实现——我抓起了C11语言规范。

这个问题处在后缀操作符的细节。在我们的案例中,我们在单个表达式里对数组下标执行了三次后缀自增。当计算后缀操作符时,它返回变量的初始值。把新的值再分配回变量是一个副作用。结果是,那个副作用只被定义为只被付诸于各顺序点之间。参照标准的5.1.2.3章节,那里定义了顺序点的细节。但在我们的例子中,我们的表达式展示了未定义行为。它完全取决于编译器对于 什么时候 给变量分配新值的副作用会执行 相对于表达式的其他部分。

最终,我俩都学到了一点新的C语言知识。众所周知,最好的应用是避免构造复杂的前缀后缀表达式,这就是一个关于为什么要这样的极好例子。



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值