前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >程序员C语言快速上手——进阶篇(七)

程序员C语言快速上手——进阶篇(七)

作者头像
arcticfox
修改2019-06-26 18:42:13
1.2K0
修改2019-06-26 18:42:13
举报
  • 进阶语法
    • 模块化编程
      • 多个源文件
      • 使用头文件
        • 关于头文件的总结
    • 预处理
      • 预处理概述
        • 文件包含
        • 宏定义
        • 条件编译
      • 预处理的高级使用
        • 普通宏
        • 带参的宏
        • 条件编译
        • 其他预处理指令

进阶语法

模块化编程

所谓模块化开发,是对源文件的一种组织方式。

多个源文件

最早的C语言仅仅用来编写小而美的代码,总共不超过100行,随着计算机软件的发展,小程序变成了大型软件工程,整个项目是由多人协同开发完成的,一个人显然已经玩不动了,这时候也就出现了模块化编程的概念。

假设现在有小明、小张和小王三人,这三人决定同时开发一个C程序,由小明负责主函数的编写和调用,小张编写一个加法函数,小王编写一个减法函数。这时候三人显然不能同时编辑同一个源码文件,那么就需要每个人编写一个源码文件。

小明的源码 main.c

代码语言:javascript
复制
 1 #include <stdio.h>
 2
 3 //声明但不实现
 4 int add(int a, int b);
 5 int sub(int a, int b);
 6
 7 int main(){
 8    printf("1+2=%d",add(1,2));
 9    printf("18-9=%d",sub(18,9));
10    return 0;
11 }

小张的加法源码 t1.c

代码语言:javascript
复制
1 int add(int a, int b){
2    return a+b;
3 }

小王的减法源码 t2.c

代码语言:javascript
复制
1 int sub(int a, int b){
2    return a-b;
3 }

小明写完代码时,发现另两位早就完成了,接下来三人将三份源码放到一起执行编译,这里使用gcc命令编译

代码语言:javascript
复制
1 gcc t1.c t2.c main.c -o main

多个源文件之间使用空格分隔,执行命令之后生成main.exe可执行程序,运行结果:

代码语言:javascript
复制
1 1+2=3
2 18-9=9

整个过程非常清晰,三人只需提前商议好各自编写的函数名和参数即可开干。

使用头文件

上面的例子是比较简单的演示,但当真实项目中,有几十上百的函数要编写时,多人协作就会显得有些混乱,而且声明与实现混合,代码结构也变得冗长。特别是当完成项目之后,我们需要给每个函数编写注释,解释函数的功能和用法时,会变得很麻烦,非常不易于阅读和维护。这时候我们就需要一种被称为头文件的文本文件,来描述函数。

之前我们一直使用别人的头文件,现在自己也来做一份头文件,创建calculate.h文件,并将函数声明都挪到头文件中

代码语言:javascript
复制
1 /* 加法函数 */
2 int add(int a, int b);
3
4 /* 减法函数 */
5 int sub(int a, int b);

这时main.c源文件就变得更简单清晰了

代码语言:javascript
复制
1 #include <stdio.h>
2 #include "calculate.h"  //包含头文件
3
4 int main(){
5    printf("1+2=%d\n",add(1,2));
6    printf("18-9=%d\n",sub(18,9));
7    return 0;
8 }

再次执行命令编译,成功!

代码语言:javascript
复制
1 gcc t1.c t2.c main.c -o main

这里有几点需要注意

  1. 头文件和.c源文件放到一个文件夹下
  2. 我们自己本地的头文件,在包含时应当写英文双引号,而不是尖括号

有了头文件以后,我们的声明都可以放到头文件中,然后在源码文件的顶部去包含它。这就是将声明和实现分离,声明单独放一个文件,实现放在源码文件中。这种开发模式,就是模块化开发,也被人称为面向接口的开发。

试想一下,在多人开发之前,大家只要协商好头文件,后面就只需要对照着头文件去写代码,省了很多事。开发完成后,将源代码编译,这时候头文件就相当于一份功能说明书,可以很方便的将二进制和头文件一同发布。

关于头文件的总结

以上例子是演示完了,但细心的朋友会发现,这里还遗留了一些问题。

  1. 头文件到底是什么?
  2. 头文件一定要和源代码放在一起吗?
  3. 在包含头文件时,<>""到底有什么区别?

首先回答第一个问题,头文件实际上并不是什么特殊的东西,它仅是一个普通的文本文件,它也可以是任意后缀名的文本文件。例如,我们将calculate.h文件改为calculate.txt,包含时使用#include "calculate.txt",再次使用gcc t1.c t2.c main.c -o main编译,完全没有任何问题。

第二个问题,头文件是可以放置到本机的任意文件夹下的。但一定要学会如何处理头文件路径问题。当我们想将头文件和C语言源文件放在同一根路径下时,为了方便查看,可以单独为头文件再创建一个目录,例如创建一个head目录,将头文件移入,则需要使用相对路径包含的写法#include "head/calculate.h"

当头文件和源代码不在同一级目录下时,则可以为其指定绝对路径,这时又有两种方法。首先将头文件移入到其他盘的任意目录

  • gcc参数指定头文件目录。这里使用-I后面紧跟路径的写法。注意I和路径之间没有空格
代码语言:javascript
复制
1 gcc t1.c t2.c main.c -o main -ID:\workspace\head
  • 配置环境变量C_INCLUDE_PATH。这里是设置地临时环境变量
代码语言:javascript
复制
1 set C_INCLUDE_PATH=D:\workspace\head 
2 gcc t1.c t2.c main.c -o main

最后说一下包含头文件时<>""的区别。关于这个区别,很多资料和教材的说法都是错误的。大多解释为尖括号用来包含标准库的头文件,双引号包含自己写的头文件。这只是很肤浅的表面现象。实际上两者的区别仅仅是参照物的区别,更简单的说就是路径的区别,和是不是标准库头文件或自定义头文件没有关联。这一点很重要,特别是在自己编写或修改开源库构建脚本,编译大型C语言工程时。

当我们的头文件和源文件在同一级目录时,这时候的头文件路径是以源文件(.c文件)路径为参照物的,因此当使用双引号来包含;当我们的头文件和源文件不在同一级目录下时,且使用上述两种方式之一指定了头文件路径,那么使用<>""来包含头文件都可以

验证,将#include "calculate.h" 改为#include <calculate.h>,使用命令编译

代码语言:javascript
复制
1 gcc t1.c t2.c main.c -ID:\workspace\head -o main

我们指定了头文件路径,编译成功。这里我们的calculate.h明显不是所谓的标准库头文件,但是编译运行没问题,说明尖括号包含头文件,只和路径有关,与是不是标准库无关,因此看到尖括号包含的头文件时,不要想当然的认为这个头文件是标准库的,特别是在处理库移植的时候。

预处理

所谓预处理,就是在办正事之前做一点准备工作。预处理指令都是以#号开头的,这一点很好辨认。

在之前,我们已经了解过了#include#define这两个指令,实际上预处理指令并不是C语言词法的一部分,它仅仅是写给编译器看的,让编译器在正式编译之前,先帮我们做点小事情。

学习预处理最好的方法,就是将C语言的预处理-编译-汇编-链接四个阶段拆开,分步进行,这时候正好体现出使用gcc命令行学习C语言的优势。

首先为了简单,先去除标准库的头文件包含,代码如下

代码语言:javascript
复制
1 #include "calculate.h"  //包含头文件
2
3 int main(){
4    printf("1+2=%d\n",add(1,2));
5    printf("18-9=%d\n",sub(18,9));
6    return 0;
7 }

使用gcc进行预处理,这里加-E参数预处理,-o指定生成的文件名

代码语言:javascript
复制
1 gcc -E main.c -o main.i

执行命令后,生成了预处理之后的源文件main.i

代码语言:javascript
复制
 1 # 1 "main.c"
 2 # 1 "<built-in>"
 3 # 1 "<command-line>"
 4 # 1 "main.c"
 5
 6 # 1 "D:\\workspace\\head/calculate.h" 1 3
 7
 8 # 3 "D:\\workspace\\head/calculate.h" 3
 9 int add(int a, int b);
10 int sub(int a, int b);
11 # 3 "main.c" 2
12
13 # 5 "main.c"
14 int main(){
15    printf("1+2=%d\n",add(1,2));
16    printf("18-9=%d\n",sub(18,9));
17    return 0;
18 }

这个文件很简单,只是将calculate.h中的声明都复制到了当前的源文件中来。到现在就很容易理解预处理指令#include了吧,就是在正式编译代码之前,帮我们把头文件中的声明拷贝到源文件中,这说明C语言中,那些声明最终还是必须得写到源文件中的。这件事被称为声明展开

预编译完成之后,接下来需要汇编了,不过我们得先把<stdio.h>头文件加回来,重新预编译一次,加了<stdio.h>之后,main.i变得很大,这是因为<stdio.h>里的声明太多了。

编译 -S 生成汇编代码

代码语言:javascript
复制
1 gcc -S main.i -o main.s
2 gcc -S t1.c -o t1.s
3 gcc -S t2.c -o t1.s

汇编 -c 生成机器码,亦称为目标文件。

代码语言:javascript
复制
1 gcc -c main.s -o main.o
2 gcc -c t1.c -o t1.o
3 gcc -c t2.c -o t2.o

这一次我们生成的.o文件就无法阅读了,已经是二进制文件了,但它还不是可执行文件。

链接 生成可执行程序main.exe

代码语言:javascript
复制
1 gcc main.o t1.o t2.o -o main

预处理概述

大多数预处理指令可分为三类

文件包含

使用#include指令包含一个指定文件

宏定义

使用#define指令定义一个宏 使用#undef指令删除一个宏

之前说用#define来定义常量,实际上就是利用宏的预处理,进行字符串替换而已。现在我们就使用gcc命令要验证

编写以下代码main.c

代码语言:javascript
复制
1 #define PI 3.14
2
3 int main(){
4    int r = PI *10 + PI*PI;
5    return 0;
6 }

不生成文件了,直接在命令行打印预处理结果

代码语言:javascript
复制
1 gcc -E main.c

输出:

代码语言:javascript
复制
1 # 1 "main.c"
2 # 1 "<built-in>"
3 # 1 "<command-line>"
4 # 1 "main.c"
5
6
7 int main(){
8    int r = 3.14 *10 + 3.14*3.14;
9 }

可以很清楚的看到,预处理之后,将所有的PI进行了文本替换。

条件编译

包含#if#ifdefifndef等,使预处理器可以根据条件确定是否将一段文本包含

条件编译就更简单了,修改main.c

代码语言:javascript
复制
1 #define PI 3.14
2
3 int main(){
4    int a = 0;
5 #if 0
6    int r = PI *10 + PI*PI;
7 #endif
8    return 0;
9 }

预编译输出

代码语言:javascript
复制
1 # 1 "main.c"
2 # 1 "<built-in>"
3 # 1 "<command-line>"
4 # 1 "main.c"
5
6
7 int main(){
8    int a = 0;
9 }

可以看到,当使用条件预处理指令#if时,判断的条件为0,直接就将包裹的代码删除了,实际上在真正的编译之后的程序中 ,根本就不存在这些内容,等同你从来没写过。

关于预编译指令,需要记住几点

  1. #开头的预处理指令必须顶格写,前面不要有空格
  2. 记住三大类预处理指令的特点,#include指令是声明展开,宏定义是文本替换,条件编译是直接删除代码。

预处理的高级使用

在预处理指令中,最复杂的是宏定义。很多人学了C语言,信心满满的要学习一下C语言开源库的代码,结果看过之后如同看天书,瞬间开始怀疑人生,感觉自己学了假的C语言。实际上据我观察,高校教材中的所谓C语言,顶多只能算是C语言的皮毛,连入门都算不上。那么问题到底出在哪呢?

我个人认为,看不懂C语言代码,百分之六十的原因就出在预处理指令的宏上面,可以说,宏是C语言中最灵活,最头疼,最复杂的东西,即使你很熟悉宏,看到宏依然会头大。特别是宏函数,非安全编程范式,代码出了问题也很难查。

说了这么多,在学习宏之前,还是先来看点有意思的东西。

创建头文件replace.h

代码语言:javascript
复制
1 #define zhengshu int
2 #define zifuchuan char*
3 #define fanhui return
4 #define ruguo if
5 #define fouze else

编写main.c

代码语言:javascript
复制
 1 #include <stdio.h>
 2 #include "replace.h"
 3
 4 zhengshu $(){
 5    zifuchuan yijuhua = "this is chinese";
 6    zhengshu a = 1;
 7    ruguo (a > 2){
 8        printf("a>2\n");
 9    }fouze{
10        printf(yijuhua);
11    }
12
13    fanhui 0;
14 }

打印结果:

代码语言:javascript
复制
1 this is chinese

上面的代码完全可以正常编译运行,这虽然是个比较极端的例子,但是说明会玩宏的人,能把C语言玩得谁都不认识!

普通宏
代码语言:javascript
复制
1 #define 标识符 替换列表
2 #define PI 3.1514

宏的替换列表可以包括标识符、关键字、数值、字符串常量、操作符等。当预处理器遇到一个宏时,会做一个“标识符”代表“替换列表”的记录,在文件后面,不管标识符在哪出现,都会被替换列表的内容替换。有一点需要注意,定义一个宏时,替换列表允许为空。

带参的宏

也称函数式宏,宏函数。

代码语言:javascript
复制
1 #define 标识符(a,b,c,...,d) 替换列表
2 #define MAX(x,y) ((x)>(y)?(x):(y))

如上,预处理器会在后面将所有的MAX(x,y)替换为后面替换列表的内容,其中x、y分别对应后面替换列表中的x、y

关于宏函数的注意事项

代码语言:javascript
复制
1 max = MAX(i++,j);

如上例,错误的使用宏函数,可能得到预期之外的结果,上例在预处理之后,被替换为如下代码,i会被加两次:

代码语言:javascript
复制
1 max = ((i++) > (j)?(i++):(j));

关于小括号的注意事项 1、如果宏替换列表中有运算符号,那么必须将整个替换列表放入小括号中 #define TOW_PI (2*3.14)

2、如果宏有参数,那么每个参数在替换列表中出现时,都要放在小括号中 #define MAX(x,y) ((x)>(y)?(x):(y))

运算符 宏定义包含两个专用运算符###

  • # 运算符可以用来字符串化宏函数里的参数,它出现在带参数宏的替换列表中。
代码语言:javascript
复制
1 #define PRINT_INT(n) printf(#n "=%d\n",n)
2
3 PRINT_INT(i/j);
4 //宏展开为
5 printf("i/j""=%d\n",i/j)
6 //等价于(C语言相邻字符串字面量会被合并)
7 printf("i/j=%d\n",i/j)
  • ## 运算符可以将两个记号(如标识符)粘合在一起。
代码语言:javascript
复制
1 #define MK_ID(n) i##n
2
3 int MK_ID(1),MK_ID(2);
4 //宏展开后
5 int i1,i2;

实际代码示例

代码语言:javascript
复制
 1 #define GENERIC_MAX(type)    \
 2    type type##_max(type x,type y){ \
 3        return x > y ? x : y;
 4    }
 5
 6 //需要float类型的求最大值函数,则可以如下定义
 7 GENERIC_MAX(float)
 8
 9 //宏展开为
10 float float_max(float x,float y){
11    return x > y ? x : y;
12 }

这样,就可以使用一个宏函数,生成对各种基本类型数据求最大值的max函数了。

创建包含多条语句的宏

使用do-while编写多条语句宏是一种C语言的技巧。

代码语言:javascript
复制
1 #define ECHO(s) \
2    do{
3        gets(s);
4        puts(s);
5    }while(0)
6
7 ECHO(str);
8 //宏展开后
9 do{gets(str);puts(str);}while(0);

预定义宏

关于宏的一些总结

  1. 使用宏函数,可以减少函数栈的调用,稍微提升一点性能,相当于C++中的内联的概念,在C99中也实现了内联函数的新特性。缺点是宏展开后,增加了编译后的体积大小。
  2. 宏参数没有类型检查,缺少安全机制。
  3. 宏的替换列表可以包含对其他宏的调用
  4. 宏定义的作用范围,直到出现这个宏的文件末尾
  5. 宏不能被定义两次,除非新定义与旧定义完全一样
  6. 可以使用#undef 标识符取消宏定义,若宏不存在,则该指令没有作用
条件编译
  • #if#endif 1#define DEBUG 1 2 3 4/* #if和#endif成对出现,#if后面跟常量表达式, 50为false,反之true。当为0时,它们之间的代码在预处理时会被删除 */ 6 7#if DEBUG 8printf("this is debug!\n"); 9#endif 需要注意,#if后面的标识符如未被定义过时,则当作值为0处理,因此默认为0时,可以不用定义该宏。
  • defined运算符 1#define DEBUG 2 3#if defined DEBUG 4... 5#endif 检测其后的标识符是否有定义过,若定义过则返回1,否则返回0
  • #ifdef#ifndef #ifdef指令用于检测一个标识符是否已经被定义为宏,#ifndef则相反,检测一个标识符是否未被定义为宏 1#ifdef 标识符 2 3/* 它等价于以下指令 */ 4#if defined 标识符
  • #elif#else 这两个指令结合#if使用,相当于C语言中的if…else if…else的用法。这两个指令还可以与#ifdef#ifndef结合使用 1#if 表达式1 2... 3#elif 表达式2 4... 5#else 6... 7#endif

条件编译主要可以用于 1、需要测试调试代码时,打印更多信息,正式发布时则去除这些代码 2、跨平台,跨编译器。对于不同平台,可以包含不同的代码,使用不同的编译器特性 3、屏蔽代码。使用注释符号注释代码时,有一个缺点,注释无法嵌套,即不能注释中间包含注释的代码,使用条件编译则很方便

其他预处理指令
  • #error 指令 可以用于检查某些编译器属性,当不符合时,提示错误,并终止编译。 1#error 消息
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-06-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 编程之路从0到1 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 进阶语法
    • 模块化编程
      • 多个源文件
      • 使用头文件
    • 预处理
      • 预处理概述
      • 预处理的高级使用
相关产品与服务
云开发 CloudBase
云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为200万+企业和开发者提供高可用、自动弹性扩缩的后端云服务,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用等),避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档