程序员内功心法之程序环境和预处理(2)

简介: 程序员内功心法之程序环境和预处理(2)

4、#define 替换规则

在程序中扩展#define定义符号和宏时,需要涉及如下几个步骤:


    在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号;如果是,它们首先被替换。

. 替换文本随后被插入到程序中原来文本的位置;对于宏来说,参数名被他们的值所替换。

最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号;如果是,就重复上述处理过程。

需要注意的是:

    1. 宏参数和 #define 定义中可以出现其他 #define 定义的符号;但是对于宏,不能出现递归。
    2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

    5、# 和 ##

    对于 # 的作用,我们以下面的代码为例:

    int main()
    {
      int a = 10;
      printf("The value of a is %d\n", a);
      int b = 20;
      printf("The value of b is %d\n", b);
      return 0;
    }

    对于上面的代码,我们发现 printf 函数里面的内容有一些冗余,有人就想我们可不可以封装一个函数或者一个宏,来实现既可以打印 a,也可以打印 b?首先,函数是做不到的,因为 the value of a / the value of b 这里不同的地方在函数中是固定的,我们只能改变打印的值得大小;这时有人就设计出了一个宏:

    #define PRINT(n) printf("The value of "#n" is %d\n", n)
    int main()
    {
      int a = 10;
      PRINT(a);
      int b = 20;
      PRINT(b);
      return 0;
    }

    2020062310470442.png

    我们可以看到,在PRINT 宏中,我们配合 # 实现了我们想要的功能,这时 # 的作用也体现出来了:# 可以把参数插入到字符串中。

    对于 ## 的作用,我们也用一个例子来说明:

    #define CLA(class, num) class##num
    int main()
    {
      int class001 = 100;
      printf("%d\n", CLA(class, 001));
      return 0;
    }

    2020062310470442.png

    所以 ## 的作用就是:##可以把位于它两边的符号合成一个符号

    注意:# 和 ## 在我们日常代码中基本不用,大家在遇到这样的代码时知道它的意思就行了。

    6、带副作用的宏参数

    当宏参数在宏的定义中出现超过一次的时候,如果参数本身带有副作用,那么我们在使用这个宏的时候就可能出现危险,导致不可预测的后果;副作用就是表达式求值的时候出现的永久性效果。

    x+1;  //不带副作用
    x++;  //带有副作用

    下面的例子可以模拟具有副作用的参数所引起的问题:

    #define MAX(a,b) ((a)>(b)?(a):(b))
    int main()
    {
      int x = 5;
      int y = 8;
      int z = MAX(x++, y++);
      printf("x=%d y=%d z=%d\n", x, y, z);
      return 0;
    }

    2020062310470442.png

    实际上上面的代码经过预处理之后 z 的表达式变成:

    z = ((x++) > (y++) ? (x++) : (y++))

    所以上面的代码不仅不能得到我们想要的 z 的值,还会让 x 和 y 的值变得不可控。

    7、宏和函数对比

    宏相较于函数的优点

    1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多;所以宏比函数在程序的规模和速度方面更胜一筹。
    2. 函数的参数必须声明为特定的类型,而宏是类型无关的,一个宏可以完成不同类型的计算任务。

    宏相较于函数的缺点

    1. 每次使用宏的时候,一份宏定义的代码将插入到程序中;除非宏比较短,否则可能大幅度增加程序的长度。
    2. 宏是没法调试的,因为在预处理阶段宏就会被全部替换掉。
    3. 宏由于类型无关,也就不够严谨。
    4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
    5. 宏不能递归。

    基于上面的结论,宏通常被应用于执行简单的运算,而复杂、代码量大的运算通常由函数来完成。

    宏有时候可以做函数做不到的事情;比如:宏的参数可以出现类型,但是函数做不到。例如:

    #define MALLOC(num, type) (type *)malloc(num * sizeof(type))
    MALLOC(10, int);//类型作为参数
    //预处理器替换之后:
    (int*)malloc(10 * sizeof(int));

    宏与函数的详细对比

    image.png

    8、命名约定

    由于函数的宏的使用语法很相似,所以语言本身没法帮我们区分二者; 为了区分宏和函数,我们平时的一个习惯是:

    • 宏名全部大写。
    • 函数名不要全部大写。

    9、#undef

    这条指令用于移除一个宏定义:

    #undef NAME
    //如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除

    10、命令行定义

    许多C 的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。例如:当我们想根据同一个源文件编译出不同的一个程序的不同版本的时候,就可以使用命令行定义。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一 个机器内存大写,我们需要一个数组能够大写。)

    #include <stdio.h>
    int main()
    {
        int array[ARRAY_SIZE];
        int i = 0;
        for (i = 0; i < ARRAY_SIZE; i++)
        {
            array[i] = i;
        }
        for (i = 0; i < ARRAY_SIZE; i++)
        {
            printf("%d ", array[i]);
        }
        printf("\n");
        return 0;
    }

    11、条件编译

    在编译一个程序的时候,我们如果要将一条语句(一组语句)编译或者放弃,我们就可以使用条件编译指令。比如说:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

    #include <stdio.h>
    #define __DEBUG__
    int main()
    {
      int i = 0;
      int arr[10] = { 0 };
      for (i = 0; i < 10; i++)
      {
    #ifdef __DEBUG__
        printf("%d\n", arr[i]);//为了观察数组是否赋值成功。
    #endif //__DEBUG__
      }
      return 0;
    }

    常见的条件编译指令:

    1.
    #if 常量表达式
     //...
    #endif
    //常量表达式由预处理器求值。
    如:
    #define __DEBUG__ 1
    #if __DEBUG__
     //..
    #endif
    2.多个分支的条件编译
    #if 常量表达式
     //...
    #elif 常量表达式
     //...
    #else
     //...
    #endif
    3.判断是否被定义
    #if defined(symbol)
    #ifdef symbol
    #if !defined(symbol)
    #ifndef symbol
    4.嵌套指令
    #if defined(OS_UNIX)
      #ifdef OPTION1
          unix_version_option1();
        #endif
        #ifdef OPTION2
        unix_version_option2();
        #endif
    #elif defined(OS_MSDOS)
      #ifdef OPTION2
        msdos_version_option2();
        #endif
    #endif

    12、文件包含

    1、头文件被包含的方式

    我们常用的文件包含方式有两种:库文件包含和本地文件包含。

    库文件包含

    包含形式:#include <filename>

    查找策略:查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。

    本地文件包含

    包含形式:#include “filename”

    查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件;如果找不到就提示编译错误。

    所以实际上对于库文件也可以使用 “” 的形式包含,但是这样做查找的效率就低一些,并且也不容易区分是库文件还是本地文件了。

    2、嵌套文件包含

    有些时候我们的程序中会出现同一个头文件被重复包含的情况,比如一个 stdio.h 头文件被包含多次,这时我们可以使用条件编译来避免头文件被重复包含:

    #ifndef __TEST_H__
    #define __TEST_H__
    //头文件的内容
    #endif   //__TEST_H__

    我们也可以在程序中加入这句话来防止重复包含:

    #pragma once

    六、相关练习题

    1、习题1

    2020062310470442.png

    2、习题2

    2020062310470442.png

    答案:C 我们知道,程序在编译阶段会进行符号汇总,然后在汇编阶段生成符号表,最后在链接阶段进行符号表的合并与重定位,如果我们调用的函数未定义,那么在汇编阶段生成符号表的时候与该函数符号相关联的地址就是无效的,那么我们链接进行合并时就会抛出链接性错误。

    3、习题3

    下面文件中定义的四个变量中,哪个变量不是指针类型?

    #define INT_PTR int*
    typedef int* int_ptr;
    INT_PTR a,b;
    int_ptr c,d;

    答案:b 我们知道 #define 最后会进行符号替换,而 typedef 是类型重命名,相当于给 int* 重新起一个名字叫 int_ptr,所以 c d 都是指针类型,而 b 的前面还应该有一颗 * 。

    4、习题4

    下面代码的执行结果是什么?

    #define N 4
    #define Y(n) ((N+2)*n) 
    int main()
    {
      int z = 2 * (N + Y(5 + 1));
      printf("%d\n", z);
      return 0;
    }

    答案:70

    上面代码经过预处理之后的结果是:

    #define N 4
    #define Y(n) ((4+2)*5+1) 
    int main()
    {
      int z = 2 * (4 + ((4+2)*5+1));
      printf("%d\n", z);
      return 0;
    }

    所以结果为 70。

    5、习题5

    模拟实现 offsetof 宏:由于 offsetof 宏的模拟实现我在结构体中已经讲了,所以这里我就直接给结论了,如果对 offset 的模拟实现有问题的同学可以看看我前面的文章 – 【C语言】自定义类型详解:结构体、枚举、联合

    #include <stdio.h>
    #define OFFSETOF(type, member) (size_t)&(((type*)0)->member)
    struct S1
    {
      char c1;
      int i;
      char c2;
    };
    int main()
    {
      printf("%d\n", OFFSETOF(struct S1, c1));
      printf("%d\n", OFFSETOF(struct S1, i));
      printf("%d\n", OFFSETOF(struct S1, c2));
      return 0;
    }

    2020062310470442.png

    6、习题6

    写一个宏,可以将一个整数的二进制位的奇数位和偶数位交换。

    思路分析

    要把一个整数的二进制位的奇数位和偶数位交换,我们可以分为三步:第一步,将该数的奇数位全部向右移动一位;第二步,将该数的偶数位全部向左移动一位;第三步,将移动后的奇数位和偶数位相加。(假设位数从0开始)下面,我们来具体实现。

    第一步:我们可以让该数按位与上二进制的 10101010 10101010 10101010 10101010,使得该数的偶数位全部变为0,然后将该数向右移动一位。

    第二步:我们可以让该数按位与上二进制的 01010101 01010101 01010101 01010101,使得该数的奇数位全部变为0,然后将该数向左移动一位。

    第三步,将移动后的奇数位和偶数位相加。

    代码实现

    #define MOVE_BIN_DIG(n) n=((n&0xaaaaaaaa)>>1)+((n&11111111)<<1)
    // aaaaaaaa:10101010 10101010 10101010 10101010的十六进制形式
    // 11111111:01010101 01010101 01010101 01010101的十六进制形式

    2020062310470442.png





    相关文章
    |
    Java
    编程中最难的就是命名?这几招教你快速上手(4)
    编程中最难的就是命名?这几招教你快速上手
    80 0
    编程中最难的就是命名?这几招教你快速上手(4)
    |
    程序员 编译器 Linux
    程序员进阶之路:程序环境和预处理(二)
    程序员进阶之路:程序环境和预处理(二)
    34 0
    |
    3月前
    |
    存储 IDE 开发工具
    |
    6月前
    |
    IDE 安全 程序员
    揭秘如何用C编写出无敌的程序代码,你绝对会后悔错过!
    揭秘如何用C编写出无敌的程序代码,你绝对会后悔错过!
    39 1
    |
    Java 程序员 编译器
    编程中最难的就是命名?这几招教你快速上手(1)
    编程中最难的就是命名?这几招教你快速上手(1)
    79 0
    编程中最难的就是命名?这几招教你快速上手(1)
    编程中最难的就是命名?这几招教你快速上手(2)
    编程中最难的就是命名?这几招教你快速上手
    48 0
    编程中最难的就是命名?这几招教你快速上手(2)
    |
    关系型数据库
    编程中最难的就是命名?这几招教你快速上手(3)
    编程中最难的就是命名?这几招教你快速上手
    55 0
    |
    算法 程序员 编译器
    当程序遇上困难:程序调试的艺术(VS)
    当程序遇上困难:程序调试的艺术(VS)
    68 0
    |
    存储 自然语言处理 程序员
    程序员进阶之路:程序环境和预处理(一)
    程序员进阶之路:程序环境和预处理(一)
    64 0
    |
    Java 关系型数据库 程序员
    编程中最难的就是命名?这几招教你快速上手
    编程中最难的就是命名?这几招教你快速上手
    722 11