C语言拾遗

  1. 数组与指针的区别
    1. 例子
  2. 数据表示
    1. 判断整数溢出
    2. 浮点数表示
      1. 规范化表示
      2. 舍入误差
    3. 例子
  3. 输入/出缓冲
  4. 预处理器

数组与指针的区别

  • 对于编译器来说,一个数组就是一个地址,而一个指针就是一个地址的地址。

  • C语言把数组下标改写成指针偏移量的根本原因是指针和偏移量是底层硬件使用的模型。

  • 对于数组的操作本质上分为两类,一类是取首元素的地址,一类是对其取sizeof操作,其他操作都是对指针的操作。

  • 可以等价的情况

    • 在表达式里使用,可以选择数组或指针如c=a[1]与c=*(a+1)等价(或者说对数组下标的引用总是可以写成一个起始地址加上偏移量的形式)
    • 作为函数参数时,可以选择数组或指针如void f(int a[])与void f(int *a)等价
  • 不可等价的情况

    • extern申明时,extern char a[]与extern char *a;不等价
    • 定义时,char a[]={“abc”};与char *a=”abc”不等价,前者是一个数组,可以改变数组内容,后者是一个指针,指向一个常量字符串,不能改变字符串内容

例子

extern char a[]与extern char *a;不等价

//a.c
char a[] = "hello";
//b.c
extern char *a;
int main()
{
    printf("%s\n", a);
    return 0;
}

上述代码会报错,因为a.c中定义的是一个数组,而b.c中申明的是一个指针,二者不等价,可以用gdb调试查看,可以发现 &a才是数组首元素地址
因为本质上声明一个数组就是声明了一块连续的地方,编译完成后会确实有一块连续的地方存储数组元素(可以用nm a.out查看具体的偏移地址),取
a[i]直接取a的偏移地址加上i*sizeof(a[0])即可,而定义的指针在编译完成后也会分配到一个小空间,这个空间存储的是一个地址
因此当定义了一个char a[]而对其声明为char *a时,编译器会取到a在链接时的地址,并将其存储的内容解释为一个地址,跳转到这个地址,而实际上链接地址a那里放的是字符串”hello”,将其解释为地址很可能出现地址错误

char *与char[]不等价

void f(int argc, char *argv[])
{
    for(int i=0; i<argc; i++)
    {
        printf("%s\n", argv[i]);
    }
}
int main()
{
    char argv[][20] = {"hello", "world"};
    f(2, argv);
    return 0;
}

上述代码会报错,因为argv是一个二维数组,而f函数的参数是一个指针数组,二者不等价
在f中argv[i]是一个指针,而在main中argv[i]是一个数组
再从另一个角度来看,f中的argv是一个装指针的数组,每个元素大小已知,argv+1与argv+2都可以计算出来,而如果能接受任意一个二维数组,由于第二维
大小未知,是无法计算出argv+1的地址的,这也是为什么申明函数参数时不能直接写成char argv[][]的原因

数据表示

判断整数溢出

整数溢出是相对于有符号整数来说的,无符号整数不会溢出,而是会回绕。
if(a+b<0)的使用并不总是可靠的,因为在对溢出行为是未定义的,机器行为无法确定,合理的方式可以是if(b>0&&a>INT_MAX-b)if(b<0&&a<INT_MIN-b),INT_MAX是limits.h中定义的最大整数。
对于某些情况可以改写表达式来避免一些溢出,如二分查找中mid=(left+right)/2可以改写成mid=left+(right-left)/2,这样可以避免部分left+right溢出。

浮点数表示

目前绝大多数机器都使用IEEE 754标准,这里只介绍一些关于编程中要知道的内容,有时间再单独学习ieee 754标准并另开一篇文章介绍。
在线浮点数转换工具:https://www.h-schmidt.net/FloatConverter/IEEE754.html
以32位浮点数为例,也就是C里的float类型,其组成如下表所示:

符号位 指数位 尾数位
1 8 23
  • 符号位:0表示正数,1表示负数
  • 指数位(阶码):采用移码表示(因为好比较阶码大小),偏移量使用的是127,所以能够表示的数据范围是-127~128,但是两个端点特殊值有特殊含义,所以实际的阶码表示范围为-126~127
  • 尾数位:23位二进制小数,由于采用规格化表示,最高位默认为1,节约空间就不存这个1,所以在ieee 754的尾数中可以看到有些最高位不是1,因为在它之前已经默认有一个1了,所以尾数的范围是12,而不是01
规范化表示
  • 所有位都是0,表示0
  • 非规范化数:指数位全为0,尾数位不全为0,这种情况下指数为-126,尾数位表示的是一个很小的数,尾数位的最高位不是1,而是0,所以这种情况下尾数位表示的是0~1之间的数
  • 规范化数:指数位不全为0,尾数位不全为0
  • 无穷大:指数位全为1,尾数位全为0
  • NaN:指数位全为1,尾数位不全为0
舍入误差

本质上由于浮点数的尾数位有限,所以浮点数无法精确表示所有的实数,所以在计算中会产生舍入误差,这种误差是不可避免的,比如0.1在二进制中是一个无限循环小数,所以在计算机中是无法精确表示的,所以在计算中会产生误差。

例子

  • 浮点数比较,由于浮点数的舍入误差,所以不能直接用==来比较浮点数是否相等
#include <stdio.h>
#include <math.h>
int main()
{
    //float在十进制下的精度是6~7位
    float a = 0.10000001f;
    float b = 0.1f;
    if(a==b)
        printf("a==b\n");
    else
        printf("a!=b\n");
    //需要使用fabs函数来比较浮点数是否相等
    if(fabs(a-b)<1e-8)
        printf("a==b\n");
    else
        printf("a!=b\n");
    return 0;
}
  • 快速计算平方根
//经典的雷神之锤代码
float Q_rsqrt( float number )
{
    long i;
    float x2, y;
    const float threeHalfs = 1.5f;

    x2 = number * 0.5f;
    y  = number;
    i  = * ( long * ) &y;                       // evil floating point bit level hacking
    i  = 0x5f3759df - ( i >> 1 );               // What the fuck? 
    y  = * ( float * ) &i;
    y  = y * ( threeHalfs - ( x2 * y * y ) );   // 1st iteration
//    y  = y * ( threeHalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed
    return y;
}
  • 浮点数快速转整数
    参考原文
    在线工具转换
    #define DOUBLE2INT(i, d) { double t = ((d) + 6755399441055744.0); i = *((int *)(&t)); }
    
    6755399441055744.0是1.5*2^52,在IEEE 754下的双精度表示下,尾数最高位1,剩下全为0,假设有一个浮点数3.5,它的规格化应该表示为1.11*2^1,由于IEEE 754标准浮点数加减需要对阶,小阶向大阶看齐,需要将1.11*2^1规格化,由于尾数位有限会发现原先3.5的小数部分的1会被丢失只剩下整数部分的内容。类似的其它浮点数也会有此现象,因此此时只要将尾数部分的低32位当作整数解释就能得到原先的整数部分了,之所以要选1.5*2^52而不是1*2^52是因为需要补一个最高位的1防止负数进行转换时的借位导致错误,类似的可以用1.5*2^23作float的magic number,但只能用char这样的少于22位的数据来转换

输入/出缓冲

stdio.h中有一个setbuf(FILE *stream, char *buf)函数,可以用来设置输入/出缓冲,如果buf为NULL,则关闭缓冲,如果buf不为NULL,则设置缓冲区为buf,大小为BUFSIZ,BUFSIZE是stdio.h中定义的一个宏,表示缓冲区大小。缓冲的作用可以减少IO次数,提高效率。比如

int main()
{
    static char buf[BUFSIZ];
    setbuf(stdout, buf);
    printf("hello world");
    printf("HI");
    printf("BYE");
    return 0;
}

当输出内容不足缓冲区大小时,不会立即输出,而是等到缓冲区满了或者调用fflush(stdout)函数才会真正调用相关的系统调用输出到终端。
利用strace跟踪上述程序会发现最终只有一个write系统调用,如果注释掉setbuf(stdout, buf)这一行,会发现有三个write系统调用。

另外buf必须是一个非运行时分配的空间,因为buf最后一次清理是在main结束时,如果是运行时分配的空间,那么在main结束时会被释放,系统在清理buf时会出现段错误。
如果setbuf传入的指针是NULL则表示不对其进行缓冲,这在某些时候比较有用,比如一些程序在崩溃时没能来得及清理其输出缓冲,导致崩溃前的一些信息未能输出

预处理器

对C的预处理器本质的认识要知道预处理器只是对C程序文本的处理,不涉及程序本身,所以预处理器严格上不算C语言的一部分

  • 宏定义中的空格
    #define f (x) x*x与
    #define f(x) x*x是不同的,前者会将f展开为(x) x*x,而后者会将f(x)展开为x*x
    但在宏调用中,f (x)与f(x)是等价的

  • 宏不是函数
    函数会保证参数只被计算一次,而宏不会,比如

#define max(a, b) ((a)>(b)?(a):(b))
int main()
{
    int a = 1;
    int b = 2;
    printf("%d\n", max(a++, b++));
    printf("%d\n", a);
    printf("%d\n", b);
    return 0;
}
//max(a++, b++)会展开为((a++)>(b++)?(a++):(b++)),所以a和b总有一个会被累加两次,与预想的结果不同
//或者遇到max(*p++, *p++)这种情况,需要慎重考虑宏的使用
  • 宏不是语句
    考虑assert的宏定义
#define assert(expr) if (!expr) __assert_fail(#expr, __FILE__, __LINE__, __func__)
上述定义在下面的代码中会出现问题
if (a > 0)
    assert(a > 0);
else
    printf("a<=0\n");
//assert里的if语句会与else匹配,导致与预期不符,因为assert本身不是一个语句,不会被编译器认为是一个整体,甚至编译器不会感知到assert的存在想要解决这个问题可以使用do{}while(0)的技巧
#define assert(expr) do{if (!expr) __assert_fail(#expr, __FILE__, __LINE__, __func__)}while(0)
//这样将assert包裹在do{}while(0)中,使其成为一个语句,不会与else匹配或者
#define assert(expr) ((void)(expr||__assert_fail(#expr, __FILE__, __LINE__, __func__)))
//也能将其转换为一个语句

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 2128099421@qq.com

×

喜欢就点赞,疼爱就打赏