预处理进阶篇-宏定义的实际应用:条件编译、文件包含以及其他预处理操作

前言

经过前面两篇博客的铺垫,C程序执行过程宏定义基本使用及注意点 已经对宏定义有了基本认识,本篇博客将进一步深入 宏定义,介绍 宏定义更多的应用,比如条件编译,文件包含等,还会介绍其他 宏定义的预处理操作,比如宏处理打印报错信息,宏处理转化字符串等操作,搞清楚预处理的方方面面。

主要使用 Linux 平台演示,具体为 CentOS7

条件编译

条件编译在程序的预处理阶段生效

条件编译,其实就是编译器根据实际情况,对代码进行裁剪。而这里“实际情况”,取决于运行平台,代码本身的业务逻辑等。
可以认为有两个好处:

  1. 可以只保留当前最需要的代码逻辑,其他去掉。可以减少生成的代码大小
  2. 可以写出跨平台的代码,让一个具体的业务,在不同平台编译的时候,可以有同样的表现

条件编译的具体使用场景:
我们经常听说过,某某版代码是完全版/精简版,某某版代码是商用版/校园版,某某软件是基础版/扩展版等。
其实这些软件在公司内部都是项目,而项目本质是有多个源文件构成的。所以,所谓的不同版本,本质其实就是功能的有无,在技术层面上,公司为了好维护,可以维护多种版本,当然,也可以使用条件编译,你想用哪个版本,就使用哪种条件进行裁剪就行。
著名的Linux内核,功能上,其实也是使用条件编译进行功能裁剪的,来满足不同平台的软件。

条件编译如何使用

这里需要解释 宏是否被定义 和 宏是否为真/假 :

#define DEBUG 	//宏被定义
#define DEBUG 1 //宏为真,宏被定义
#define DEBUG 0 //宏为假,宏被定义

在使用 ifdefifndef 时,判断的都是 宏是否被定义

有如下代码:

#include <stdio.h>

int main()
{
    #define DEBUG
    #ifdef DEBUG
    printf("hello debug\n");
    #endif
    return 0;
}

如果 没有#define DEBUG就是没有该宏定义,下面的printf语句就不会被执行了。同样宏定义也可以使用判断语句,如下:

#include <stdio.h>

int main()
{
#ifdef DEBUG
    printf("hello debug\n");
#elif RELEASE
    printf("hello release\n");
#else 
    printf("hello unknow\n");
#endif
    return 0;
}

运行效果:
image

由于没有定义 宏,所以就输出了 hello unknow

可以简单地认为,条件编译就是一个代码裁剪的工具

//定义宏,下面的 仅仅检测宏是否定义,而不在意 宏是否为真
#define M
#define N
//如果定义了 宏M
# ifdef M
//如果没有定义 宏M
# ifndef
//如果不满足 #ifdef的 条件M ,而满足宏定义 N 的条件
#elif N 
//满足其他条件
#else

使用 #if 可以对宏的 真/假 进行判断

代码如下:

#include <stdio.h>
#define DEBUG 1
int  main()
{
#if DEBUG
    printf("hello debug\n");
#elif RELEASE
    printf("hello release\n");
#else 
    printf("hello unknow\n");
#endif
    return 0;
}

运行效果:
image

命令行中条件编译的使用

宏定义,不仅仅可以在代码中进行定义,也可以在编译时,使用命令行定义,使用如下:
image

在Linux平台下使用命令行进行编译处理,可以使用 -D 参数,实现 命令行宏定义,但是在 IDE比如VS中就无法使用命令行了,在VS 中,可以在 解决方案资源管理器 -> 右键项目名称 -> 属性 -> 属性设置 -> C/C++下的预处理器 -> 在 [预处理器定义] 后面继续添加要使用的 宏定义参数
image

替换#ifdef

可以使用 #if defined() 来替换 #ifdef ,代码如下:

#include <stdio.h>
int  main()
{
   //替换 #ifdef
#if defined(VERSION)
    printf("hello VERSION\n");
#else
    printf("hello other!\n");
#endif
    return 0;
}

运行效果:
image

同理,#ifndef 也可以被替代

#if !defind ()	//等价于 #ifndef

注意,不论使用哪种方式,都需要使用 #endif 来结尾

多条件编译

使用多条件编译,在不同的条件下,通过明令行参数的不同,实现不同的功能,代码如下:

#include <stdio.h>
int  main()
{
#if VERSION==1
    printf("hello 1\n");
#elif VERSION==2
    printf("hello 2\n");
#elif VERSION==3
    printf("hello 3\n");
#else
    printf("hello other\n");
#endif
    return 0;
}

效果如下:
image

多条件编译的 编译条件 应该尽可能的简洁,如果在实际编写代码时,发现 代码裁剪条件太过复杂,则应该考虑代码是否合理。

除了这样,我们还可以使用条件编译实现多个条件满足,有如下代码:

#include <stdio.h>
#define C
#define CPP
int  main()
{
	//多条件编译,最好是使用 () 来约束条件
#if (defined(C) && defined(CPP))
    printf("hello C/CPP\n");
#else
    printf("hello other!\n");
#endif
    return 0;
}

运行效果:
image

其他逻辑符号也是可以使用的,如下的逻辑符号都是成立的:

//取反
#if !(defined(C) && defined(CPP))
//或
#if (defined(C) || defined(CPP))

条件编译嵌套

条件编译使用的宏定义,是可以实现 嵌套的,嵌套的逻辑与C语言中 if的嵌套逻辑是一样的。如下:

#include <stdio.h>
#define C
#define CPP
int main()
{ 
//如果定义了 宏C
#if defined(C)
    //如果定义了 宏 CPP
	#if defined (CPP)
		printf("hello CPP\n");
	#endif	//结束语句
	printf("hello C\n");
#else
	printf("hello other\n");
#endif
	return 0;
}

文件包含

文件包含是与处理的一个重要功能,它可以将多个源文件链接成一个源文件进行编译,结果将生成一个目标文件。

在进行多文件编程的时候,在头文件中,基本都会使用如下的代码:

#ifndef _TEST_H_
#define _TEST_H_

//头文件代码

#endif

使用这样的代码,目的是为了 防止头文件重复包含,原因是在第一次使用该头文件时,# ifndef 生效,执行后面的 # define 代码,定义该头文件,在之后如果再次用到该头文件,就不符合 #ifndef 了,因为该 宏 已经被定义过了,所以该 宏 下面的代码都不会被执行了,就不会被重复包含了。

所有头文件都必须带上条件编译,防止被重复包含!
那么,重复包含一定报错吗??不会!
重复包含,会引起多次拷贝(因为包含头文件,就相当于把 头文件 中的内容全部拷贝到 源文件 中),主要会影响编译效率!同时,也可能引起一些未定义错误,但是特别少。

其他预处理符

预处理都是在编译期间起效果的,也就是和后面的链接,运行没有关系。这点要注意。另外,很多预处理用的并不多,所以这里仅仅做简单介绍。

#error预处理

相当于自定义报错信息,由编译器打印一条报错信息

代码如下:

#include <stdio.h>

int main()
{
#ifndef CPP
#error sorry, it is not CPP
#endif
    printf("hello world\n");
    return 0;
}

演示效果如下:
image

#line预处理

订制文件名 和 代码行号

#include <stdio.h>
int main()
{
    printf("%s, %d\n", __FILE__, __LINE__); //C预定义符号,代表当前文件名和代码行号
    #line 60 "hehe.h" //定制化完成
    printf("%s, %d\n", __FILE__, __LINE__);
    system("pause");
    return 0;
}

#pragma预处理

#pragma message("消息文本") 作用:可以用来进行对代码中特定的符号(比如其他宏定义)进行是否存在进行编译时消息提醒

比如在VS 中很常用的防止 scanf 和 printf报错的,避免 4996报错的方法:

#include <stdio.h>
#include <windows.h>

#pragma warning(disable:4996) //禁止4996报错,
int main()
{
    int x = 10;
    scanf("%d", &x);
    printf("hello : %d\n", x);
    system("pause");
    return 0;
}

#pragma message("消息") 与 #error 不同,# pragma 不会报错,只会打印出信息,而不会影响程序运行。

#include <stdio.h>
#define M 10
int main()
{ 
    #ifdef M
    #pragma message("M宏已经被定义了")
    #endif
    
    system("pause");
    return 0;
}

运行效果:
image

一般使用这个方法来检测 某些宏 是否存在,方便进行条件编译

其他使用

在所有的预处理指令中, # pragma 指令可能是最复杂的了,它的所用是 设定编译器的状态 或是 指示编译器完成一些特定的动作。

在 VS 中较为常用的是 # pragma once 一般用在 .h 的头文件中,可以使该文件只被包含一次,防止重复包含的问题,但是考虑到跨平台的兼容性,使用频率就不是很高

# pragma 后面一般是使用的参数,还有其他很多使用的参数,这里不再一一给出,具体可以参考原书《C语言深度剖析(第二版)》3.6节:97

#pragma pack 与内存对齐

内存对齐

内存对齐,是指在 结构体中,该结构体所占的空间的大小,并不是 结构体中的所有变量类型的大小的和,而是在满足内存对齐后的大小,一般内存对齐后的大小都是要 大于等于 变量类型大小之和的。

为什么要有内存对齐?
单字节、双字节、四字节 在 自然边界(偶数地址、可以被4整除的地址、可以被8整除的地址) 上不需要内存对齐。无论如何,为了提高程序的性能,数据结构(尤其是栈) 应该尽可能的在自然边界上对齐,这样可以减少 处理器内存访问的次数,如果是未对齐的内存,处理器需要进行两次内存访问才可以访问到这两个数据。

怎样是内存未对齐?
一个字节或双字节 操作数跨越了 4 字节边界,或者一个 四字节 操作数跨越了8字节边界,就被认为是未对齐的,这样的就需要两次 总线周期 的内存访问。一个字节其实地址是奇数但是没有跨越字节边界,也认为是对齐的(能够在一个 总线周期 内访问)

在 缺省 情况下,编译器默认将 结构体、栈 中的成员数据进行内存对齐,如下的结构体:

struct TestStruct1
{
    char c1;	//0000 0000-0000 0001	后面补空格
    short s;	//0000 0002-0000 0004 	short这里认为是2字节
    char c2;	//0000 0004-0000 0008	这里需要进行内存对齐
    int i;		//0000 0008-0000 0012
};
//所以该结构体的大小为12

编译器自动将 未对齐的成员向后移,将每一个成员都对齐到自然边界上,这样虽然会使整个结构体变大(牺牲内存空间),但是可以提高性能

我们可以在编码时 避免内存的影响 ,既达到提高性能的目的,又能节约一点空间,比如将上面的结构体改为如下结果,就可以使每个成员都对齐在自然边界上:

struct TestStruct1
{
    char c1;	//0000 0000-0000 0001	
    char c2;	//0000 0001-0000 0002	
    short s;	//0000 0002-0000 0004  
    int i;		//0000 0004-0000 0008
};
//所以该结构体的大小为8

使用该编码规范,在这个结构体作为 API 的一部分提供给第三方开发使用时,第三方开发者可能将编译器的默认对齐方式选项 改变,这样 对齐方式的不同,可能会产生大问题。

这里可以使用 pragma 的选项,改变内存对齐方式的选项:

#pragma pack(n)		//编译器按照n 字节对齐
#pragma pack()		//编译器取消自定义字节对齐方式,使用默认对齐方式

这里使用 n字节对齐的含义,并不是所有成员都以 n 字节对齐,其对齐规则为:每个成员按其类型的对齐参数(通常是这个类型的大小)和指定的对齐参数中的较小的一个对齐,即 min(n,sizeof(item)),并且结构的长度必须为所用过的所有对齐参数的整数倍,不够就补空字节。

有如下例子:

# pragma pack(8)
struct TestStruct4
{
    char a;		//0000 0000-0000 0001	后面补空格
    long b;		//0000 0004-0000 0008	这里需要进行内存对齐,long 大小为4,4<8 这里就按照4对齐,为8
};	//TestStruct4 大小为8
struct TestStruct5
{
    char c;			//0000 0000-0000 0001	后面补空格
    TestStruct4 d;	//0000 0004-0000 0012	进行内存对齐,默认对齐方式为结构体 所有成员使用的对齐参数的最大的一个,即 TestStruct4 的对齐参数为4,大小为8
    long long e;	//0000 0016-0000 0024
};	//24可以被对齐参数8整除,所以TestStruct 大小为24
#pragma pack()
  • 每个成员分别按照自己的对齐方式对齐,并能最小化长度
  • 复杂类型 的默认对齐方式是它 最长的成员的对齐方式,这样在成员是复杂类型时可以最小化长度
  • 对齐后的长度必须是成员中最大的对齐参数的整数倍,这样在处理数组时可以保证每一项的边界都对齐,比如 char a[3] 还是按照 1字节对齐,而不是它的长度
  • 不论类型是什么,对齐的边界都一定是 1,2,4,8,16,32,64…中的一个

#处理符

我们知道,在C语言中,相邻字符串有自动连接特性 比如如下代码,都是可以正常输出的:

printf("hello"" world""\n");	//相邻字符串 自动连接特性

const char *msg = "hello""bit""\n";
printf(msg);

转化字符串

在 C语言中,可以将 参数符号对应的文本内容,直接转化为字符串,如下:

#include <stdio.h>
#include <math.h>

#define STR(s) #s
int main()
{
    //可以直接输出 PI:3.1415926
	printf("PI: "STR(3.1415926)"\n");
	return 0;
}

由于 宏定义 的处理过程是在 预处理阶段进行的,所以无法通过预处理 将变量的内容,转化为 字符串

要时刻记得,宏定义 就是一个替换的过程

##预算符

将 宏定义与符号想连,产生一个新的符号,这里可以理解为 ## 就是一个粘合剂,可以粘合合法的C语言标识符。具体使用可以参考下面代码:

#include <stdio.h>
#define XNAME(n) student##n
int main()
{
    XNAME(1);
    XNAME(2);
    XNAME(3);
    XNAME(4);
    XNAME(5);
    XNAME(6);

    return 0;
}

运行效果:
image

也可以用来处理科学计数法的数学表示,如下:

#include <stdio.h>
#define CONT(x,n) (x##e##n)
int main()
{
    printf("%f\n",CONT(1.1,2)); //计算浮点数的科学计数法表示,相当于 1.1*10^2 或 1.1+e2
    return 0;
}

最后

感谢观赏,慢慢提高,一起变强

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值