C 语言中你想不到的一些问题

作者: | 更新日期:

虽然自己敲了4年多代码了,但是我不敢说自己精通C语言,今天研究了一番C语言。简单介绍几个有意思的问题吧

本文首发于公众号:天空的代码世界,微信号:tiankonguse

前言

自己虽然一直交叉的敲着 C 和 c plus plus 两种语言,但是其实自己就是使用一下常用的语法。
工作后又没有那么时间来看书,于是研究了一些C语言的细节来学习学习。

建议看的时候先不要看问题分析,这样才能考察自己到底会不会的。

遍历数组

问题

有时候我们要遍历一个不知道大小的数组,但是我们有数组的名字,于是我们可以通过 sizeof 获得数组的大小了。
有了大小我们就可以遍历这个数组了。
一般情况下大家都是从下标 0 开始计数,于是从来不会遇到下面的问题。
如果你遇到下面的问题你能想出是什么原因吗?

代码

#include<stdio.h>

#define TOTAL_ELEMENTS (sizeof(array) / sizeof(array[0]))
int array[] = {23,34,12,17,204,99,16};

int main() {
    for(int d=-1; d <= (TOTAL_ELEMENTS-2); d++) {
        printf("%d\n",array[d+1]);
    }


    return 0;
}

输出


Process returned 0 (0x0)   execution time : 0.050 s
Press any key to continue.
















end

分析

这个原因我一提,大家也都可以想到。
sizeof 返回的类型是 unsigned int .
unsigned int 与 int 进行运算还是 unsigned int。
然后 -1 和 unsigned int 比较,会先把 -1 转化为 unsigned int。
这样 -1 的 unsigned int 就很大了,所以没有输出了。

do while

问题

大家在 do while 中使用过 continue 吗?
没有的话来看看这个问题吧。

代码

#include<stdio.h>

int main() {
    int i=1;
    do {
        printf("%d\n",i);
        i++;
        if(i < 15) {
            continue;
        }
    } while(false);
    return 0;
}

输出

1

Process returned 0 (0x0)   execution time : 0.041 s
Press any key to continue.











end

分析

这个需要查看文档了: continue 会到循环的哪个地方继续运行。
于是我查看了官方文档

For the for loop, continue causes the conditional test and increment portions of the loop to execute.
For the while and do…while loops, program control passes to the conditional tests.

什么意思呢?
for 循环遇到 continue 会执行for 小括号内的第三个语句。
while 和 do…while 则会跳到循环判断的地方。

宏的展开

问题

大多数情况下,我们的宏定义常常是嵌套的。
这就涉及到展开宏定义的顺序了。
下面来看看其中一个问题。

代码

#include <stdio.h>

#define f(a,b) a##b
#define g(a)   #a
#define h(a) g(a)

int main() {
    printf("%s\n",h(f(1,2)));
    printf("%s\n",g(f(1,2)));
    return 0;
}

输出

12
f(1,2)

Process returned 0 (0x0)   execution time : 0.041 s
Press any key to continue.











end

分析

这个问题又需要查看文档了: Macro 是怎么展开的。
于是我查看了官方文档

Macro arguments are completely macro-expanded before they are substituted into a macro body, unless they are stringified or pasted with other tokens.
After substitution, the entire macro body, including the substituted arguments, is scanned again for macros to be expanded.
The result is that the arguments are scanned twice to expand macro calls in them.

简单的说就是 宏会扫描一遍,把可以展开的展开,展开一次后会再扫描一次看又没有可以展开的宏。
下面我们模拟一下这个过程就可以明白了。

对于第一个,是下面的过程。

  ↓ 
> h(f(1,2))
    ↓ 
> g(f(1,2))
    ↓
> g(12)
  ↓
> g(12)
  ↓
> "12"

对于第二个,是这个过程。

  ↓
> g(f(1,2))
  ↓
> "f(1,2)"

问题

你知道 printf 的返回值是什么吗?
猜猜下面的代码输出是什么吧。

代码

#include <stdio.h>
int main() {
    int i=43;
    printf("%d\n",printf("%d",printf("%d",i)));
    return 0;
}

输出

4321

Process returned 0 (0x0)   execution time : 0.035 s
Press any key to continue.













end

分析

printf 的返回值是输出的字符的长度。
所以第一个输出 43 返回2.
第二个输出 2 返回 1. 第三个输出1. 于是输出的就是 4321 了。

数组参数

问题

对于函数传参为数组时,你知道到底传的是什么吗?

代码

#include<stdio.h>
#define SIZE 10
void size(int arr[SIZE][SIZE]) {
    printf("%d %d\n",sizeof(arr),sizeof(arr[0]));
}

int main() {
    int arr[SIZE][SIZE];
    size(arr);
    return 0;
}

输出

4 40

Process returned 0 (0x0)   execution time : 0.039 s
Press any key to continue.













end

分析

对于第二个输出,应该是 40 这个大家都没有什么疑问的。
但是第一个是几呢?
你是不是想着是 400 呢?
答案是 4.
这是因为对于数组参数。第一级永远是指针形式。
也就是说数组参数永远是指针数组。
所以第一级永远是指针,而剩下的级数由于需要使用 [] 运算符, 所以不能是指针。

sizeof 的参数

问题

当我们有时候想让代码简洁点的时候,会把运算压缩到一起。
但是在 sizeof 中就要小心了。

代码

#include <stdio.h>
int main() {
    int i;
    i = 10;
    printf("i : %d\n",i);
    printf("sizeof(i++) is: %d\n",sizeof(i++));
    printf("i : %d\n",i);
    return 0;
}

输出

i : 10
sizeof(i++) is: 4
i : 10

Process returned 0 (0x0)   execution time : 0.039 s
Press any key to continue.










end

分析

你猜第二个i的输出时什么呢?
11 吗?
恭喜你,猜错了。

这个还需要查看文档了。
首先我的印象中 sizeof 是个宏,在编译器运算的。

The sizeof is a keyword, but it is a compile-time operator that determines the size, in bytes, of a variable or data type.

文档上说 sizeof 是一个关键字,但是在编译器运算。
所以编译器是不会进行我们的那些算术等运算的。
而是直接根据返回值推导类型,然后根据类型推导出大小的。

位运算左移

问题

这个问题没什么说的,你运行一下就会先感到诧异,然后会感觉确实应该是这个样字,甚至会骂这代码写的太不规范了。

代码

#include <stdio.h>
#define PrintInt(expr) printf("%s : %d\n",#expr,(expr))
int FiveTimes(int a) {
    return a<<2 + a;
}

int main() {
    PrintInt(FiveTimes(1));
    return 0;
}

输出

FiveTimes(1) : 8

Process returned 0 (0x0)   execution time : 0.624 s
Press any key to continue.











end

分析

需要我提示吗?
三个字:优先级

浮点数

问题

大家经常使用 浮点数,知道背后的原理吗?

代码

#include <stdio.h>
int main() {
    float a = 12.5;
    printf("%d\n", a);
    printf("%d\n", *(int *)&a);
    return 0;
}

输出

12.500000
1095237632

Process returned 0 (0x0)   execution time : 0.651 s
Press any key to continue.











end

分析

为了方便大家测试,我提供了一个32位浮点型向二进制的转换器

首先 int 和 float 在 32位机器上都是 四字节的。

对于整数储存,大家都没什么疑问。

比如 10 的二进制,十六进制如下

00000000 00000000 00000000 00001010
0X0000000A

由于最高位代表符号,所以整数可以表示的范围就是

0X80000000 -2^31 
0XFFFFFFFF -1 负整数最小值
0X00000000 0
0X00000001 1 正整数最小值
0X7FFFFFFF 2^31-1 正整数最大值

上面的二进制也就决定了 4字节的整数范围是 -2 ^ 312 ^ 31 - 1 .

对于一个浮点数,可以表示为 (-1)^S * X * 2^Y .
其中 S 是符号,使用一位表示。
X 是一个 二进制在 [1, 2) 内的小数,一般称为尾数,用23位表示。
Y 是一个整数,代表幂,一般称为阶码,用8位表示。

其中 Y 又涉及符号问题了。
8位的Y可以表示0到255,取指数偏移值 127 (2^(8-1) - 1)作为分界线,小于127的数是负数,大于的是正数。
这里我不明白为什么不使用以前数字的表示方法。

比如 12.5 的二进制是 1100.1 。
转化为上面的公式就是 (-1)^0 * 1.1001 * 2^3

下面我们来推导一下这个数字的二进制是什么吧。

符号为正,所以第一位就是0了

3 + 127 就是 130 了。于是使用 10000010 可以表示。

1.1001 一般不表示小数前的1,于是只需要表示 1001 即可,于是使用 10010000000000000000000 就可以表示了。

于是 12.5 的 float 的二进制表示就推算出来了

0 10000010 10010000000000000000000
01000001 01001000 00000000 00000000

然后这个二进制对应着整数 1095237632 。
这样一切都解释清楚了。

当然还要注意一个问题,这里有这么一个特殊规定:阶码Y如果是0, 尾数X就不再加1了。

宏的定义

问题

大家都定义过宏吧,你的宏定义规范吗?

代码

#include <stdio.h>

#define MUL(x,y) (x)*(y)
#define MUL1(x,y) x*y
#define LOG(msg) printf("line:%d\n",__LINE__);printf("msg:%s\n",msg)

int main() {
    int a = 2, b = 3, c;

    c = MUL(a+1, b+1);
    printf("%d * %d :mul = %d\n", a, b, c);

    c = MUL1(a+1, b+1);
    printf("%d * %d :mul1 = %d\n", a, b, c);

    c = MUL(a+1, b+1);
    if(c != 12)
        LOG("mul error");

    return 0;
}

输出

2 * 3 :mul = 12
2 * 3 :mul1 = 6
msg:mul error

Process returned 0 (0x0)   execution time : 0.039 s
Press any key to continue.
















end

分析

是的,编程语言中所有的坑大部分都是代码编写不规范导致的。

但是你想让所有人的代码都规范的编写代码是很难得。

比如我在给大一新生讲 C 语言时多次强调 在所有的需要加大括号的地方都要加大括号 ,即使只有一条语句也要加,但是有几个人做得到呢?

比如使用队列时,下面的语句你加大括号吗?

while(!que.empty()){
    que.pop();
}

下面我们来看看上面的代码为什么错了,以及怎么解决。

首先大家可能都清楚我们的宏变量都需要括号括起来。

比如 MUL, 如果不加括号, 就是 MUL1 了, 然后输出变成 6 了。

为什么是 6 呢?

我们模拟一下展开过程

//a = 2, b = 3
MUL1(a+1, b+1)
a+1 * b+1 
a + b + 1
6

很好,不加括号确实应该是6.

那第二个为什么会输出 msg:mul error 呢?

展开后再格式化后我们可以看到是下面的样子

if(c != 12)
    LOG("mul error");
        
=>
if(c != 12)
    printf("line:%d\n",__LINE__);printf("msg:%s\n",msg);

=>

if(c != 12)
    printf("line:%d\n",__LINE__);
    
printf("msg:%s\n",msg);
    

大家应该会想到需要在宏里面加大括号,但是我们该如何加呢?

加之前大家可以先看看我们的宏定义

#define MUL(x,y) (x)*(y)
#define MUL1(x,y) x*y
#define LOG(msg) printf("line:%d\n",__LINE__);printf("msg:%s\n",msg)

我们的宏定义后面都缺少一个分号,为什么这样做呢?

为了满足视觉上的合理性,即调用时我们往往会在后面加一个分号

c = MUL(a+1, b+1);
c = MUL1(a+1, b+1);
LOG("mul error");

好的,这个背景介绍完了,我们来看看加上大括号后的样子吧。

加大括号的时候肯定是先把分号补上了。

#define MUL(x,y) {(x)*(y);}
#define MUL1(x,y) {x*y;}
#define LOG(msg) {printf("line:%d\n",__LINE__);printf("msg:%s\n",msg);}

然后我们惊奇的发现竟然编译不过去。

当然如果你编译过去了,只是简单的收到几个警告,那说明你用的是最新版本的支持C++11的编译器。

这里假设你编译不过去了。

为什么编译不过去呢?

我们把第一个宏展开看看是什么吧。

c = {(a+1)*(b+1);};

天呢,这是什么东西,显然是有问题的。

好吧,这个问题我们没办法解决了。

在我们不知道怎么办的时候,我们意外的发现 LOG("mul error"); 竟然编译过去了。

为什么呢? 再次展开一下

if(c != 12)
    LOG("mul error");

=>

if(c != 12)
    {printf("line:%d\n",__LINE__);printf("msg:%s\n",("mul error"));};
    
=>

if(c != 12){
    printf("line:%d\n",__LINE__);
    printf("msg:%s\n",("mul error"));
}
;

好吧,除了最后多了一个分号空语句,其他的地方都是完美的。

但是这个可恶的分号会影响 else 语句的。

if(c != 12)
    LOG("mul error");
else
    printf("ok\n");

=>

if(c != 12){
    printf("line:%d\n",__LINE__);
    printf("msg:%s\n",("mul error"));
}
;
else
    printf("ok\n");   

这个时候又会编译不过去的。

但是这个错误是由于我们的代码编写不规范导致的,我们通过加大括号可以解决。

if(c != 12){
    LOG("mul error");
}else{
    printf("ok\n");
}

=>

if(c != 12){
    {
        printf("line:%d\n",__LINE__);
        printf("msg:%s\n",("mul error"));
    }
    ;
}else{
    printf("ok\n"); 
}

虽然加大括号后展开的代码看着比较奇怪,但是最起码我们暂时解决了一个问题。

你也知道,大部分程序员都是有洁癖的。

因此怎么能容忍这么丑陋的代码存在呢?

于是我们需要寻找看起来比较优美的展开代码。

程序员的智慧是无限的,还真找到两个来。

#define FOO(X) do { something;} while (0)
#define FOO(X) if (1) { something; } else

上面两个代码就是比较优美的代码,展开后是这个样子

#define LOG(msg) do {printf("line:%d\n",__LINE__);printf("msg:%s\n",msg);} while (0)

if(c != 12){
    LOG("mul error");
}else{
    printf("ok\n");
}
    
=>

if(c != 12){
    do {
        printf("line:%d\n",__LINE__);
        printf("msg:%s\n",msg);
    }  while (0);
}else{
    printf("ok\n");
}

#define LOG(msg) if (1) { printf("line:%d\n",__LINE__);printf("msg:%s\n",msg); } else

if(c != 12){
    LOG("mul error");
}else{
    printf("ok\n");
}
    
=>

if(c != 12){
    if (1) { 
        printf("line:%d\n",__LINE__);
        printf("msg:%s\n",msg);
     } else ;
}else{
    printf("ok\n");
}

上面两个代码看起来优美多了,而且 do{}while(0) 用的更多一些,毕竟 else ; 看着还是有那么一点不舒服。

但是优美归优美,我们还是需要把所有问题解决了才算真正的优美。

那对于 这个丑陋的代码 {(a+1)*(b+1);}; 到底该如何解决呢?

这么丑陋的代码是由这个语言本身的语法导致的,所以只好从语言本身上解决了。

简单的说使用目前的方法没办法完美解决,即定义一个规则不能满足所有情况,于是官网提供了一个新的语法

c++11 定义了新的语法:

#define MUL(x,y) ({(x)*(y);})

c = ({(a+1)*(b+1);});

看到了什么?

简单的说就是对宏语句加个大括号,然后大括号外加个小括号。

最后一个值作为返回值。

这和很多解释性语言的函数类似,最后一条语句的返回值作为函数的返回值。

这个问题颇为复杂,不过前端时间我写了这么一篇文章 宏 do{}while(0)., 感兴趣的可以看一看。

看到这,有些人可能会感觉有点不对劲,但是那里不对劲有说不上来。

于是再看一遍,找到原因了:作者在误导人啊。

为什么呢?

还记得上面加大括号的时候我们是往宏上加的,有的人可能会说我们先把代码写规范了,在看看会有那些问题呗。

#include <stdio.h>

#define MUL(x,y) ((x) * (y))
#define LOG(msg) printf("line:%d\n",__LINE__);printf("msg:%s\n",msg)

int main() {
    int a = 2, b = 3, c;

    c = MUL(a+1, b+1);
    printf("%d * %d :mul = %d\n", a, b, c);

    if(c != 12) {
        LOG("mul error");
    }

    return 0;
}

理想情况下,代码先写规范了, 发现什么问题都没有。

但是现实和理想还是有差距的。

细心的人可能发现我的 MUL 这个宏和上面的宏还是有点区别的, 最外面又加了一个小括号,这也是一个坑

那究竟什么样的代码才是规范的呢?

这个不好说,因为一个语言的所有细节都要整理出来的话,那将会是一个很大的文档。

所以目前这些只好靠经验,阅读书籍,看别人的文档,一个坑一个坑的踩之后才能慢慢的了解这些细节。

如果谁知道有这么一个文档的话,可以留言告诉我,我只知道有一个简单的文档 Google C++ Style Guide .

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越