-- 简书作者 谢恩铭 转载请注明出处
第二部分第一课:模块化编程
上一课是C语言探索之旅 | 第一部分第十课:练习题+习作,至此,我们【C语言探索之旅】的第一部分结束了。
大家肯定注意到了,今天的封面图片我用了老爷子 Dennis Ritchie(丹尼斯.里奇。C语言之父)的壁纸,略表敬仰和怀念之情。
还有一个原因:因为这一部分要开始难起来了,开始学习C语言的高级技术。
图片上的The Legend,是英语“传奇”的意思。Dennis Ritchie是2011年去世的。
同年,乔布斯乔帮主也离开了我们。
不过不要忘了这个“乔布斯站在其肩头”的伟大的人,Dennis Ritchie:C语言之父(近代很多编程语言的灵感都来自C语言,例如C++,Java,Objective-C)。
Dennis Ritchie还算是Unix操作系统之父(与Ken Thompson一起,Unix是近代不少操作系统的先驱,其中Linux系统吸取了Unix系统的特点,苹果的Mac OS系统底层基于改动过的Unix,Android系统底层基于改动过的Linux系统)。
Dennis Ritchie在发明了C语言后,就用C语言重写了原本用汇编语言编写的Unix系统。
而苹果的MAC OS和iOS系统使用了诸如C语言,Objective-C,C++等来编写。
以后应该拍一系列早期计算机黑客的传奇的电影,特别是丹尼斯.里奇,肯定得拍一部纪录片。如果没有Dennis Ritchie,我们今天很多技术都没法实现。
网上有一篇图文介绍,叫《Unix英烈传:图文细数十五位计算先驱》
http://www.linuxidc.com/Linux/2013-08/89395p2.htm
还有最后一个原因:老爷子长得帅啊,给我们程序员长脸啊。
《灌篮高手》里樱木花道称呼安西教练为老爷子,愿我们的Dennis Ritchie教练也“安息”。
好了,话休絮烦,我们回归正题。
第二部分开始
话说上一课是第一部分最后一课,现在开始第二部分的探索之旅!
在这一部分中,我们会学习C语言的高级技术。这一部分内容将是一座高峰,会挺难的。但是我们一起翻越。
俗语说得好:一口是吃不成一个胖子的。
但是小口小口,慢慢吃,还是能吃成胖子的嘛。所以要细水长流,肥油慢积。一路上有你(油腻)~
一旦你跟着我们的课程一直到这一部分的结束,你将会掌握C语言的核心技术,也可以理解大部分C语言写的程序了。
之后在第三部分,我们就会一起来学习C语言图形编程,构建C语言的2D游戏等等。
到目前为止我们的程序都只是在一个main.c文件里捣腾,因为我们的程序还很短小,这也足够。
但如果之后你的程序有了十多个函数,甚至上百个函数,那么你就会感到全部放在main.c一个文件里是多么拥挤和混乱。
正因为如此,计算机科学家才想出了模块化编程。原则很简单:与其把所有源代码都放在一个main.c当中,我们将把它们合理地分割,放到不同的文件里面。
函数原型
到目前为止,写自定义函数的时候,我们都要求大家暂时把函数写在main函数的前面。
这是为什么呢?(唉呀妈呀,想到了蔡明和郭达那个小品...)
因为这里的顺序是一个重要的问题。如果你将自己定义的函数放置在main函数之前,电脑会读到它,会知道这个函数,当你在main函数中调用这个函数时,电脑知道这个函数,也知道到哪里去执行它。
但是假如你把这个函数写在main函数后面,那你在main函数里调用这个函数的时候,电脑就不认识它了,你可以自己写个程序测试一下。是的,很奇怪吧?这绝对有点任性的。
那你会说:“C语言岂不是设计得不好么?”
我完全同意(别让上面的老爷子听到了...)。但是请相信,这样设计应该也是有理由的,计算机先驱们早就想到了,也提出了解决之道。
下面我们就来学一个新的知识点,借着这个,你可以把你的自定义函数放在程序的任意位置。不必操心总是好事。
用来声明一个函数的“函数原型”
我们会声明我们的函数,用我们所说的术语:函数原型(prototype)。
就好比你对电脑发出一个通知:“看,我的函数的原型在这里,你给我记住啦”。
我们来看一下我们上一课举的一个函数的例子(计算矩形面积):
double rectangleArea(double length, double width)
{
return length * width;
}
怎么来声明我们上面这个函数的原型呢?
-
复制,黏贴第一行
-
在最后放上一个分号;
-
把这一整行放置在main函数前面
简单吗?现在你就可以把你的函数的定义放在main函数后面啦,电脑也会认识它,因为你在main函数前面已经声明过这个函数了。
你的程序会变成这样:
#include <stdio.h>
#include <stdlib.h>
// 下面这一行是rectangleArea函数的函数原型
double rectangleArea(double length, double width);
int main(int argc, char *argv[])
{
printf("长为10,宽为5的矩形面积 = %f\n", rectangleArea(10, 5));
printf("长为3.5,宽为2.5的矩形面积 = %f\n", rectangleArea(3.5, 2.5));
printf("长为9.7,宽为4.2的矩形面积 = %f\n", rectangleArea(9.7, 4.2));
return 0;
}
// 现在我们的rectangleArea函数就可以放置在程序的任意位置啦
double rectangleArea(double length, double width)
{
return length * width;
}
与原先的程序相比有什么改变呢,其实就是在程序的开头加了函数的原型而已(记得不要忘了那个分号)。
函数的原型,其实是给电脑的一个提示或指示。比如上面的程序中,函数原型
double rectangleArea(double length, double width);
就是对电脑说:“老兄,存在一个函数,它的输入是哪几个参数,输出是什么类型”,这样就能让电脑更好地管理。
多亏了这一行代码,现在你的rectangleArea函数可以置于程序的任何位置啦。
记得:最好养成习惯,对于C语言程序,总是定义了函数,再写一下函数的原型。
那么不写函数原型行不行呢?
也行。只要你把每个函数的定义都放在main函数之前,但是你的程序慢慢会越来越大,等你有几十或者几百个函数的时候,你还顾得过来么?
所以养成好习惯,不吃亏的。
你也许注意到了,main函数没有函数原型。因为不需要,main函数是每个C程序必须的入口函数。(人家“有权”,跟编译器关系好,编译器对main函数很熟悉,是经常打交道的哥们,所以不需要函数原型来“介绍”main函数)。
还有一点,在写函数原型的时候,对于圆括号里的函数参数,名字是不一定要写的,可以只写类型。
因为函数原型只是给电脑做个介绍,所以电脑只需要知道输入的参数是什么类型就够了,不需要知道名字。所以我们以上的函数原型也可以简写如下:
double rectangleArea(double, double);
看到了吗,我们可以省略length和width这两个变量名,只保留double(双精度浮点型)这个类型名字。
千万不要忘了函数原型末尾的分号,因为这是编译器区分函数原型和函数定义开头的重要指标。如果没有分号,编译时会出现比较难理解的错误提示。
头文件
每次看到这个术语,我都想到已经结婚的“我们的青春”:周杰伦 的《头文字D》。
到目前为止,我们的程序只有一个.c文件(称之为“源文件”),比如我们之前把这个.c文件命名为main.c,当然名字是无所谓的,起名为hello.c,hehe.c都行。
一个项目多个文件
在实际编写程序的时候,你的项目一般肯定不会把代码都写在一个main.c文件中。当然,可行是可行的。
但是,试想一下,如果你把所有代码都塞到这一个main.c文件中,那代码量可能达到10000多行,你要在里面找一个东西太难了。也正是因为这样,通常我们每一个项目都会创建多个文件。
那以上说到的项目是指什么呢?
之前我们用Code::Blocks这个IDE创建第一个C语言项目的时候,其实有接触过。但是我们会再解释一下。
一个项目(英语是 project),简单来说是指你的程序的所有源代码(还有一些其他的文件),项目里面的文件有多种类型。
目前我们的项目还只有一个源文件:main.c
看一下你的IDE,一般来说项目是列在左边。
如上图,你可以看到,这个项目(在Projects一栏里)只有一个文件:main.c
现在我们再来展示一个包含好多个文件的项目:
上图中,我们可以看到在这个项目里有好几个文件。实际中的项目大多是这样的,你看到那个main.c文件了吗?通常来说在我们的程序中,会把main函数只定义在main.c当中。
当然不是一定非要这样,每个人都有自己的编程风格。不过希望跟着这个课程学习的读者,可以和我们保持一致的风格,方便理解。
那你又要问了:“为什么创建多个文件呢?我怎么知道为项目创建几个文件合适呢?”
答案是:这是你的选择。通常来说,我们把同一主题的函数放在一个文件里。
.h文件和.c文件
在上图中,我们可以看到有两种类型的文件:一种是以.h结尾的,一种是以.c结尾的。
- .h文件:称为“头文件”,这些文件包含了函数的原型
- .c文件:称为“源文件”,包含了函数本身(定义)
所以,通常来说我们不常把函数原型放在.c文件中,而是放在.h文件中,除非你的程序很小。
对每个.c文件,都有同名的.h文件。上面的项目那个图中,你可以看到.h和.c文件一一对应。
- files.h和files.c
- editor.h和editor.c
- game.h和game.c
但我们的电脑怎么知道函数原型是在.c文件之外的另一种文件里呢?
需要用到我们之前介绍过的预处理指令 #include来将其引入到.c文件中。
请做好准备,下面将有一波密集的知识点“来袭”。
怎么引入一个头文件呢?其实你已经知道怎么做了,之前的课程我们已经写过了。
比如我们来看我们上面的game.c文件的开头
#include <stdlib.h>
#include <stdio.h>
#include "game.h"
void player(SDL_Surface* ecran)
{
// ...
}
看到了吗,其实你早就熟悉了,要引入头文件,只需要用#include这个预处理指令。
因此我们在game.c源文件中一共引入了三个头文件: stdlib.h, stdio.h,game.h
注意到一个不同点了吗?
在标准库的头文件(stdlib.h, stdio.h)和你自己定义的头文件(game.h)的引入方式是有点区别的:
- <> 用于引入标准库的头文件,在IDE中一般位于安装目录的include文件夹中,在linux中则一般位于系统的include文件夹里
- "" 用于引入自定义的头文件,位于你自己的项目的目录中
我们再来看一下我们的对应的game.h这个头文件的内容:
看到了吗,.h文件中存放的是函数原型。
你已经对一个项目有大致概念了。
那你又会问了:“为什么要这样安排呢?把函数原型放在.h头文件中,在.c源文件中用#include引入,为什么不把函数原型写在.c文件中呢?”
答案是:方便管理,条理清晰,不容易出错,省心。
因为如前所述,你的电脑在调用一个函数前必须先“知道”这个函数,我们需要函数原型来让使用这个函数的其他函数预先知道。
如果用了.h头文件的管理方法,在每一个.c文件开头只要用#include这个指令来引入头文件的所有内容,那么头文件中声明的所有函数原型都被当前.c文件所知道了,你就不用再操心那些函数的定义顺序或者有没有被其他函数知道
例如我的main.c函数要使用functions.c文件中的函数,那我只要在main.c的开头写 #include "functions.h",之后我在main.c函数中就可以调用function.c中定义的函数了。
你可能又要问了:“那我怎么在项目中加入新的.h和.c文件呢?”
很简单,在codeblocks里,鼠标右键点击项目列表的主菜单处,选择Add Files,或者
在菜单栏上依次单击 File->New->File...
就可以选择添加文件的类型了。
引入标准库
你脑海里肯定出现一个问题:
如果我们用#include来引入stdio.h和stdlib.h这样的标准库的头文件,而这些文件又不是我自己写的,那么它们肯定存在于电脑里的某个地方,我们可以去找到,对吧?
是的,完全正确!
如果你使用的是IDE(集成开发环境),那么它们一般就在你的IDE的安装目录里。
如果是在纯Linux环境下,那就要到系统文件夹里去找,这里不讨论了,以后开了Linux课会讲到,感兴趣的读者可以去网上搜索。
在我的情况,因为安装的是codeblocks这个IDE,所以在windows下,我的头文件们“隐藏”在这个路径下:
E:\Program Files\CodeBlocks\MinGW\include
一般来说,都在一个叫做include的文件夹里。
在里面,你会找到很多文件,都是.h文件,也就是C语言系统定义的标准头文件,也就是系统库的头文件(对windows,mac,linux都是通用的,C语言本来就是可移植的嘛)。
在这众多的头文件当中,你可以找到我们的老朋友:stdio.h和stdlib.h。
你可以双击打开这些文件或者选择你喜欢的文本编辑器来打开,不过也许你会吓一跳,因为这些文件里的内容很多,而且好些是我们还没学到的用法,比如除了#include以外的其他的预处理指令。
你可以看到这些头文件中充满了函数原型,比如你可以在stdio.h中找到printf函数的原型。
你要问了:“ok,现在我已经知道标准库的头文件在哪里了,那与之对应的标准库的源文件(.c文件)在哪里呢?”
不好意思,你见不到它们啦。因为.c文件已经被事先编译好,转换成计算机能理解的二进制码了。
“伊人已去,年华不复,吾将何去何从?”
既然见不到原先的它们了,至少让我见一下“美图秀秀”之后的它们吧…
可以啊,你在一个叫lib的文件夹下面就可以找到,在我的Windows下的路经为:
E:\Program Files\CodeBlocks\MinGW\lib
被编译成二进制码的.c文件,有了一个新的后缀名:.a(在codeblocks的情况,因为编译器是mingw)或者.lib(在visual c++的情况,因为编译器是Visual),当然以后我们还会学到在linux下还有.so这个后缀名,等。暂时不深究。
这些被编译之后的文件被叫做库文件或library文件(library是英语“库”的意思),不要试着去阅读这些文件的内容,完全不是人看得懂的乱码…
学到这里可能有点晕,不过继续看下去就会渐渐明朗起来,下面的章节会有示意图帮助理解。
小结一下:
在我们的.c源文件中,我们可以用#include这个预处理指令来引入标准库的.h头文件或自己定义的头文件,这样我们就能使用标准库所定义的printf这样的函数,这样电脑就认识了这些函数(借着.h文件中的函数原型),就可以检验你调用这些函数时有没有用对,比如函数的参数个数,返回值类型等。
分开编译
现在我们知道了一个项目是由若干文件组成的,那我们就可以来了解一下编译器的工作原理。
之前的课里面展示的编译示例图是比较简化的,下图是一幅编译原理的略微详细的图,希望大家用心理解并记住:
上图将编译时所发生的事情基本详细展示了,我们来仔细分析:
- 预处理器(preprocessor):顾名思义,预处理器为编译做一些预备工作,所以预处理器是在编译之前启动的。它的任务是执行特殊的指令,这些指令是通过预处理命令给出的,预处理命令以#开头,很容易辨认。
预处理指令有好多种,目前我们学过的只有#include,它使我们可以在一个文件中引入另一个文件的内容。#include这个预处理指令也是最常用的。
预处理器会把#include所在的那一句话替换为它所引入的头文件的内容,比如
#include <stdio.h>
预处理器在执行时会把上面这句指令替换为stdio.h文件的内容。所以到了编译的时候,你的.c文件的内容会变多,包含了所有引入的头文件的内容,显得比较臃肿。
- 编译(compilation):这是核心的步骤,以前的课我们说过,正是编译把我们人类写的代码转换成计算机能理解的二进制码(0和1组成)。编译器编译一个个.c文件。对于codeblocks这样的IDE来说,就是你放在项目列表中的所有.c文件;如果你是用gcc来编译,那么你要指定编译哪几个.c文件。
编译器会把.c文件先转换成.o文件(有的编译器会生成.obj文件),.o文件一般叫做“目标文件”(o就是英语 object [目标] 的首字母),是临时的二进制文件,会被用于之后生成最终的可执行二进制文件。
.o文件一般会在编译完成后被删除(根据你的IDE的设置)。从某种程度上来说.o文件虽然是临时中间文件,好像没什么大用,但保留着不删除也是有好处:假如项目有10个.c文件,编译后生成了10个.o文件。
之后你只修改了其中的一个.c文件,如果重新编译,那么编译器不会为其他9个.c文件重新生成.o文件了,只会重新生成你更改的那个。节省资源。
- 链接器(linker):顾名思义,链接器的作用是链接,链接什么呢?就是编译器生成的.o文件,链接器把所有.o文件链接器来,“制作成”一个“大块头”:最终的可执行文件(windows下是.exe文件,linux下有不少种形式)。
现在你知道从代码到生成一个可执行程序的内部原理了吧,下面我们要展示给大家的这张图,很重要,希望大家理解并记住。
大部分的错误都会在编译阶段被显示,但也有一些是在链接的时候显示,有可能是少了.o文件之类。
之前那幅图其实还不够完整,你可能想到了:我们用.h文件引入了标准库的头文件的内容(里面主要是函数原型),函数的具体实现的代码我们还没引入呢,怎么办呢?
对了,就是之前提到过的.a或.lib这样的库文件(由标准库的.c源文件编译而成)。
所以我们的链接器(linker)的活还没完呢,它还需要负责链接标准库文件,把你自己的.c文件编译生成的.o目标文件和标准库文件整合在一起,然后链接成最终的可执行文件。
如下图所示:
这下我们的示意图终于完整了。
这样我们才有了一个完整的可执行文件,里面有它需要的所有指令的定义,比如printf的定义
之后在第三部分我们会用到系统的图形库,也是在.a库文件中定义的,包含了一系列指令的定义,比如告诉电脑怎么创建一个窗口,绘制图形,等等。好戏在后头。
变量和函数的作用范围
为了结束我们这一课,我们还必须来学习最后一个知识点:
变量和函数的作用范围(有效范围)
我们将学习它们什么时候是可以被调用的。
函数的私有变量(局部变量)
当你在一个函数里定义了一个变量之后,这个变量会在函数结尾时从内存中被删除。
int multipleTwo(int number)
{
int result = 0; // 变量result在内存中被创建
result = 2 * number;
return result;
} // 函数结束,变量result从内存中被删除
在一个函数里定义的变量,只在函数运行期间存在。
这意味着什么呢?意味着你不能从另一个函数中调用它。
#include <stdio.h>
int multipleTwo(int number);
int main(int argc, char *argv[])
{
printf("15的两倍是 %d\n", multipleTwo(15));
printf("15的两倍是 %d", result); // 错误!
return 0;
}
int multipleTwo(int number)
{
int result = 0;
result = 2 * number;
return result;
}
可以看到,在main函数中,我们试着调用result这个变量,但是因为这个变量是在multipleTwo函数中定义的,在main函数中就不能调用,会出错。
记住:在函数里定义的变量只能在函数内部使用,我们称之为局部变量。
全局变量:避免使用
能被所有文件使用的全局变量
我们可以定义能被项目的所有文件的所有函数调用的变量。我们会展示给大家怎么做,为了告知大家这方法存在,但是一般来说,要避免使用能被所有文件使用的全局变量。
可能这样做一开始会让你的代码简单一些,但是不久你就会为之烦恼了。
为了创建能被所有函数调用的全局变量,我们须要在函数之外定义。通常我们把这样的变量放在程序的开头,#include预处理指令的后面。
#include <stdio.h>
int result = 0; // 定义全局变量result
void multipleTwo(int number); // 函数原型
int main(int argc, char *argv[])
{
multipleTwo(15); // 调用multipleTwo函数,使全局变量result的值变为原来的两倍
printf("15的两倍是 %d\n", result); // 我们可以调用变量result
return 0;
}
void multipleTwo(int number)
{
result = 2 * number;
}
上面的程序中,我们的函数multipleTwo不再有返回值了,而是用于将result这个全局变量的值变成2倍。之后main函数可以再使用result这个变量。
由于这里的result变量是一个完全开放的全局变量,所以它可以被项目的所有文件调用,也就能被所有文件的任何函数调用。
注:这种类型的变量是很不推荐使用的,因为不安全。一般用函数里的return语句来返回一个变量的值。
只能在一个文件里被访问的全局变量
刚才我们学习的完全开放的全局变量可以被项目的所有文件访问。我们也可以使一个全局变量只能被它所在的那个文件调用。
就是说它可以被自己所在的那个文件的所有函数调用,但不能被项目的其他文件的函数调用。
怎么做呢?
只需要在变量前面加上 static 这个关键字。如下所示:
static int result = 0;
static在英语里是“静止的,不变的”之意。
函数的static(静态)变量
注意:
如果你在声明一个函数内部的变量时,在前面加上static这个关键字,它的涵义和上面我们演示的全局变量是不同的。
函数内部的变量如果加了static,那么在函数结束后,这个变量也不会销毁,它的值会保持。下一次我们再调用这个函数时,此变量会延用上一次的值。
例如:
int multipleTwo(int number)
{
static int result = 0; // 静态变量result在函数第一次被调用时创建
result = 2 * number;
return result;
} // 变量result在函数结束时不会被销毁
这到底意味着什么呢?
就是说:result这个变量的值,在下次我们调用这个函数时,会延用上一次结束调用时的值。
有点晕是吗?不要紧。来看一个小程序,以便加深理解:
#include <stdio.h>
int increment();
int main(int argc, char *argv[])
{
printf("%d\n", increment());
printf("%d\n", increment());
printf("%d\n", increment());
printf("%d\n", increment());
return 0;
}
int increment()
{
static int number = 0;
number++;
return number;
}
上述程序中,在我们第一次调用increment函数时,number变量被创建,初始值为0,然后对其做自增操作(++运算符),所以number的值变为1。
函数结束后,number变量并没有从内存中被删除,而是保存着1这个值。
之后,当我们第二次调用increment函数时,变量number的声明语句(static int number = 0;)会被跳过不执行(因为变量number还在内存里呢。你想一个皇帝还没驾崩,太子怎么能继位呢)。
我们继续使用上一次创建的number变量,这时候变量的值沿用第一次increment函数调用结束后的值:1,再对它做++操作(自加1),number的值就变为2了。
依此类推,第三次调用increment函数后number的值为3,第四次number的值为4。
所以运行程序,输出如下:
1
2
3
4
一个文件中的局部函数(本地函数或静态函数)
我们用函数的作用域来结束我们关于变量和函数的作用域的学习。
正常来说,当你在一个.c源文件中创建了一个函数,那它就是全局的,可以被项目中所有其他.c文件调用。
但是有时我们需要创建只能被本文件调用的函数,怎么做呢?
聪明如你肯定想到了:对了,就是使用static关键字,与变量类似。
把它放在函数前面。如下:
static int multipleTwo(int number)
{
// 指令
}
现在,你的函数就只能被同一个文件中的其他函数调用了,项目中的其他文件中的函数就只可远观而不可亵玩焉…
小结一下变量的所有可能的作用范围
-
在函数体内定义的变量,如果前面没加static关键字,则是局部变量,在函数结束时被删除,只能在本函数内被使用。
-
在函数体内定义,但是前面加了static关键字,则为静态变量,在函数结束时不被删除,其值也会保留。
-
在函数外面定义的变量被称为全局变量,如果前面没有static关键字,则其作用范围是整个项目的所有文件,就是说它可以被项目的所有文件的函数调用。
-
函数外面定义的变量,如果前面加了static关键字,那就只能被本文件的所有函数调用,而不能被项目其他的文件的函数调用。
同样地,小结一下函数的所有可能的作用范围
-
一个函数在默认情况下是可以被项目的所有文件的函数调用的。
-
如果我们想要一个函数只能被本文件的函数所调用,只需要在函数前加上static关键字。
总结
-
一个程序包含一个或多个.c文件(一般称为 源文件,source。当然我们一般也把所有的高级语言代码叫做源代码)。通常来说,每个.c文件都有一个和它同名但不同扩展名的.h文件(有点像同父异母的兄弟)。.c文件里面包含了函数的实际定义,而.h文件里包含函数的原型声明。
-
.h文件的内容被一个叫做预处理器(preprocessor)的程序引入到.c文件的开头。
-
.c文件被一个叫做编译器(compiler)的程序转换成.o的二进制目标文件(o是英语object的首字母,表示“对象、目标”)。
-
.o文件又被一个叫做链接器(linker)的程序连接成一个最终的.exe可执行文件(exe是英语executable的前三个字母,表示“可执行的”。在windows操作系统里可执行程序的扩展名是.exe,在linux系统里,可执行程序有不少扩展名(.elf,等),也可以没有扩展名)
-
变量和函数都有“有效范围”,某些时候是访问不到的。
第二部分第二课预告:
今天的课就到这里,一起加油咯。
下一次我们学习第二部分第二课。
咳咳,我必须正襟危坐,假装严肃地来宣布:
来认识一下C语言的精华和王牌:C语言探索之旅 | 第二部分第二课:进击的指针,C语言的王牌!
网友评论