作者 谢恩铭,公众号「程序员联盟」(微信号:coderhub)。
转载请注明出处。
原文:https://www.jianshu.com/p/207…《C 语言探索之旅》全系列
内容简介
- 前言
- 函数原型
- 头文件
- 分开编译
- 变量和函数的作用范围
- 总结
- 第二部分第二课预告
1. 前言
上一课是 C 语言探索之旅 | 第一部分练习题。
话说上一课是第一部分最后一课,现在开始第二部分的探索之旅!
在这一部分中,我们会学习 C 语言的高级技术。这一部分内容将是一座高峰,会挺难的,但是我们一起翻越。
俗语说得好:“一口是吃不成一个胖子的。”
但是一小口一小口,慢慢吃,还是能吃成胖子的嘛。所以要细水长流,肥油慢积,一路上有你(“油腻”)~
一旦你跟着我们的课程一直学到这一部分的结束,你将会掌握 C 语言的核心技术,也可以理解大部分 C 语言写的程序了。
到目前为止我们的程序都只是在一个 main.c 文件里捣腾,因为我们的程序还很短小,这也足够了。
但如果之后你的程序有了十多个函数,甚至上百个函数,那么你就会感到全部放在一个 main.c 文件里是多么拥挤和混乱。
正因为如此,计算机科学家才想出了 模块化编程。原则很简单:与其把所有源代码都放在一个 main.c 当中,我们将把它们合理地分割,放到不同的文件里面。
2. 函数原型
到目前为止,写自定义函数的时候,我们都要求大家暂时把函数写在 main 函数的前面。
这是为什么呢?
因为这里的顺序是一个重要的问题。如果你将自己定义的函数放置在 main 函数之前,电脑会读到它,就会“知道”这个函数。当你在 main 函数中调用这个函数时,电脑已经知道这个函数,也知道到哪里去执行它。
但是假如你把这个函数写在 main 函数后面,那你在 main 函数里调用这个函数的时候,电脑还不“认识”它呢。你可以自己写个程序测试一下。是的,很奇怪对吧?这绝对有点任性的。
那你会说:“C 语言岂不是设计得不好么?”
我“完全”同意(可别让 C 语言之父 Dennis Ritchie 听到了 …)。但是请相信,这样设计应该也是有理由的。计算机先驱们早就想到了,也提出了解决之道。
下面我们就来学一个新的知识点,借着这个技术,你可以把你的自定义函数放在程序的任意位置。
用来声明一个函数的“函数原型”
我们会声明我们的函数,需要用到一个专门的技术:函数原型,英语是 function prototype。function 表示“函数”,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 函数很熟悉,是经常打交道的“哥们”,所以不需要函数原型来“介绍”main 函数。
还有一点,在写函数原型的时候,对于圆括号里的函数参数,名字是不一定要写的,可以只写类型。
因为函数原型只是给电脑做个介绍,所以电脑只需要知道输入的参数是什么类型就够了,不需要知道名字。所以我们以上的函数原型也可以简写如下:
double rectangleArea(double, double);
看到了吗,我们可以省略 length 和 width 这两个变量名,只保留 double(双精度浮点型)这个类型名字。
千万不要忘了函数原型末尾的分号,因为这是编译器区分函数原型和函数定义开头的重要指标。如果没有分号,编译时会出现比较难理解的错误提示。
3. 头文件
头文件在英语中是 header file。header 表示“数据头,页眉”,file 表示“文件”。
每次看到这个术语,我都想到已经结婚的“我们的青春”:周杰伦 的《头文字 D》。
到目前为止,我们的程序只有一个 .c 文件(被称为“源文件”,在英语中是 source file。source 表示“源,源头,水源”),比如我们之前把这个 .c 文件命名为 main.c。当然名字是无所谓的,起名为 hello.c,haha.c 都行。
一个项目多个文件
在实际编写程序的时候,你的项目一般肯定不会把代码都写在一个 main.c 文件中。当然,也不是不可以。
但是,试想一下,如果你把所有代码都塞到这一个 main.c 文件中,那如果代码量达到 10000 行甚至更多,你要在里面找一个东西就太难了。也正是因为这样,通常我们每一个项目都会创建多个文件。
那以上说到的项目是指什么呢?
之前我们用 CodeBlocks 这个 IDE 创建第一个 C 语言项目的时候,其实已经接触过了。
一个项目(英语是 project),简单来说是指你的程序的所有源代码(还有一些其他的文件),项目里面的文件有多种类型。
目前我们的项目还只有一个源文件:main.c。
看一下你的 IDE,一般来说项目是列在左边。
如上图,你可以看到,这个项目(在 Projects 一栏里)只有一个文件:main.c。
现在我们再来展示一个包含好多个文件的项目:
上图中,我们可以看到在这个项目里有好几个文件。实际中的项目大多是这样的。你看到那个 main.c 文件了吗?通常来说在我们的程序中,会把 main 函数只定义在 main.c 当中。
当然也不是非要这样,每个人都有自己的编程风格。不过希望跟着这个课程学习的读者,可以和我们保持一致的风格,方便理解。
那你又要问了:“为什么创建多个文件呢?我怎么知道为项目创建几个文件合适呢?”
答案是:这是你的选择。通常来说,我们把同一主题的函数放在一个文件里。
.h 文件和 .c 文件
在上图中,我们可以看到有两种类型的文件:一种是以 .h 结尾的,一种是以 .c 结尾的。
- .h 文件:header file,表示“头文件”,这些文件包含了函数的原型。
- .c 文件:source file,表示“源文件”,包含了函数本身(定义)。
所以,通常来说我们不常把函数原型放在 .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* screen)
{// ...}
看到了吗,其实你早就熟悉了,要引入头文件,只需要用 #include 这个预处理指令。
因此我们在 game.c 源文件中一共引入了三个头文件:stdlib.h,stdio.h,game.h。
注意到一个不同点了吗?
在标准库的头文件(stdlib.h,stdio.h)和你自己定义的头文件(game.h)的引入方式是有点区别的:
-
<>
用于引入标准库的头文件。对于 IDE,这些头文件一般位于 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 环境下,那就要到系统文件夹里去找,这里不讨论了,感兴趣的读者可以去网上搜索。
在我的情况,因为安装的是 CodeBlocks 这个 IDE,所以在 Windows 下,我的头文件们“隐藏”在这两个路径下:
C:\Program Files\CodeBlocks\MinGW\include
和
C:\Program Files\CodeBlocks\MinGW\x86_64-w64-mingw32\include
一般来说,都在一个叫做 include 的文件夹里。
在里面,你会找到很多文件,都是 .h 文件,也就是 C 语言系统定义的标准头文件,也就是系统库的头文件(对 Windows,macOS,Linux 都是通用的,C 语言本来就是可移植的嘛)。
在这众多的头文件当中,你可以找到我们的老朋友:stdio.h 和 stdlib.h。
你可以双击打开这些文件或者选择你喜欢的文本编辑器来打开,不过也许你会吓一跳,因为这些文件里的内容很多,而且好些是我们还没学到的用法,比如除了 #include 以外的其他的预处理指令。
你可以看到这些头文件中充满了函数原型,比如你可以在 stdio.h 中找到 printf 函数的原型。
你要问了:“OK,现在我已经知道标准库的头文件在哪里了,那与之对应的标准库的源文件(.c 文件)在哪里呢?”
不好意思,你见不到它们啦。因为 .c 文件已经被事先编译好,转换成计算机能理解的二进制码了。
“伊人已去,年华不复,吾将何去何从?”
既然见不到原先的它们了,至少让我见一下“美图秀秀”之后的它们吧…
可以,你在一个叫 lib 的文件夹下面就可以找到,在我的 Windows 下的路经为:
C:\Program Files\CodeBlocks\MinGW\lib
和
C:\Program Files\CodeBlocks\MinGW\x86_64-w64-mingw32\lib
被编译成二进制码的 .c 文件,有了一个新的后缀名:.a(在 CodeBlocks 的情况,它的编译器是 MinGW。MinGW 简单来说就是 GCC 编译器的 Windows 版本)或者 .lib(在 Visual C++ 的情况),等。这是静态链接库的情况。
你在 Windows 中还能找到 .dll 结尾的动态链接库;你在 Linux 中能找到 .so 结尾的动态链接库。暂时我们不深究静态链接库和动态链接库,有兴趣的读者可以去网上自行搜索。
这些被编译之后的文件被叫做 库文件 或 Library 文件(library 表示“库,图书馆,文库”),不要试着去阅读这些文件的内容,因为是看不懂的乱码。
学到这里可能有点晕,不过继续看下去就会渐渐明朗起来,下面的内容会有示意图帮助理解。
小结一下:
在我们的 .c 源文件中,我们可以用 #include 这个预处理指令来引入标准库的 .h 头文件或自己定义的头文件。这样我们就能使用标准库所定义的 printf 这样的函数,电脑就认识了这些函数(借着 .h 文件中的函数原型),就可以检验你调用这些函数时有没有用对,比如函数的参数个数,返回值类型,等。
4. 分开编译
现在我们知道了一个项目是由若干文件组成的,那我们就可以来了解一下编译器(compiler)的工作原理。
之前的课里面展示的编译示例图是比较简化的,下图是一幅编译原理的略微详细的图,希望大家用心理解并记住:
上图将编译时所发生的事情基本详细展示了,我们来仔细分析:
- 预处理器(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 的定义。
5. 变量和函数的作用范围
为了结束这一课,我们还得学习最后一个知识点:变量和函数的作用范围(有效范围)。
我们将学习变量和函数什么时候是可以被调用的。
函数的私有变量(局部变量)
当你在一个函数里定义了一个变量之后,这个变量会在函数结尾时从内存中被删除。
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 函数中就不能调用,编译会出错。
记住:在函数里定义的变量只能在函数内部使用,我们称之为 局部变量,英语是 local variable。local 表示“局部的,本地的”,variable 表示“变量”。
全局变量(请避免使用)
全局变量的英语是 global variable。global 表示“全局的,总体的”。
能被所有文件使用的全局变量
我们可以定义能被项目的所有文件的所有函数调用的变量。我们会展示怎么做,是为了说明这方法存在,但是一般来说,要避免使用能被所有文件使用的全局变量。
可能这样做一开始会让你的代码简单一些,但是不久你就会为之烦恼了。
为了创建能被所有函数调用的全局变量,我们须要在函数之外定义。通常我们把这样的变量放在程序的开头,#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 关键字。
6. 总结
- 一个程序包含一个或多个 .c 文件(一般称为 源文件 ,source file。当然,我们一般也把所有的高级语言代码叫做 源代码)。通常来说,每个 .c 文件都有一个和它同名但不同扩展名的 .h 文件。.c 文件里面包含了函数的实际定义,而 .h 文件里包含函数的原型声明。
- .h 文件的内容被一个叫做预处理器(preprocessor)的程序引入到 .c 文件的开头。
- .c 文件被一个叫做编译器(compiler)的程序转换成 .o 的二进制目标文件。
- .o 文件又被一个叫做链接器(linker)的程序链接成一个最终的可执行文件(在 Windows 操作系统里可执行程序的扩展名是 .exe,因为 exe 是英语 executable 的前三个字母,表示“可执行的”。在 Linux 系统里,可执行程序有不少扩展名(.elf,等),也可以没有扩展名)。
- 变量和函数都有“有效范围”,某些时候是访问不到的。
7. 第二部分第二课预告
今天的课就到这里,一起加油吧!
下一课:C 语言探索之旅 | 第二部分第二课:进击的指针,C 语言的王牌!
我是 谢恩铭,公众号「程序员联盟」(微信号:coderhub)运营者,慕课网精英讲师 Oscar 老师,终生学习者。
热爱生活,喜欢游泳,略懂烹饪。
人生格言:「向着标杆直跑」