Logo
Section 1 / 3

将程序拆分为多个文件

了解一种将代码划分为不同类别或文件的通用实践。

导入

将程序拆分为多个文件

了解一种将代码划分为不同类别或文件的通用实践。

我们迄今为止看到的程序都存储在单个源文件中。随着程序规模的增大,以及开始处理他人编写的代码(例如其他C库),我们必须应对分布在多个文件中的代码。事实上,我们可以建立自己的C函数和数据结构库,用于重复使用于科学编程和数据分析。

在这里,我们将学习如何将C函数和数据结构放在独立的文件中,并将其整合到新程序中。

代码复用

在函数章节中我们了解到,一种将自定义函数包含进C代码的方法是直接将它们写入主源文件中。你可以把函数放在main函数之前,或者放在其后,但需要在main函数上方添加函数原型声明。

更高效地复用常用于C程序中的函数的方式是将它们放在单独的文件中,并在main函数上方添加包含指令。

编译时,这相当于将代码复制粘贴到main函数之前,但从编辑和编写代码的角度来看,这种方式允许我们将内容分隔在不同的文件中。此外,如果将来决定修改某个可复用的函数(例如发现并修复了一个错误),只需在一个地方进行更改,而无需在所有程序中搜索并逐一修改。

头文件

C程序中的一种常见惯例是,为每个与主源代码链接的源文件(以*.c为后缀)编写一个对应的头文件(以.h为后缀)。其逻辑是:.c*源文件包含所有实现代码,而头文件仅包含函数原型,即说明该源文件中有哪些函数被定义。

这种做法也适用于由他人提供的库,有时这些库仅以编译后的二进制“块”形式提供(即无法查看源码)。通过搭配纯文本头文件,我们可以了解哪些函数被定义、它们接受什么参数,以及返回什么类型的值。除了函数原型外,头文件还可以包含宏定义、类型定义,以及使用const关键字声明的变量。

#include 指令

#include 指令用于将头文件或库与源文件关联。我们已经见过一个非常典型的例子:#include <stdio.h>。这表示我们要将stdio.h的所有内容包含进当前源文件。

因此,对于任意源文件,使用#include指令的模板如下:

  • #include <文件名> 或
  • #include "文件名"

示例

以下是一个计算猕猴初级运动皮层神经元在全臂伸展任务期间的“偏好方向”的程序(例如参考 Gribble & Scott, 2002 )。猴子从中心起始目标移动到圆周上的8个外围目标之一。每个目标的运动重复5次,共40次运动。

每个神经元的数据由两组40个值表示:

  • 一个包含40个值的数组,表示每次运动的运动方向(具体来说,是手部速度达到峰值时速度矢量的方向,单位为弧度)
  • 另一个包含40个值的数组,表示每次运动期间平均每秒的脉冲数(时间区间从运动开始前150毫秒起,到手部速度达到峰值结束)

目标是为每个神经元计算一个偏好方向,即神经元放电最活跃(每秒脉冲数最多)的运动方向。虽然具体的计算细节在此不重要,但假设我们已经编写了(或有人提供了)一个名为compute_PD的C函数来完成此计算,且代码存储在名为neuron.c的文件中,并配有头文件neuron.h。

以下是主程序,命名为go.c:

1#include <stdio.h> 2#include <stdlib.h> 3#include <string.h> 4#include "neuron.h" 5 6int main(int argc, char *argv[]) { 7 8 int ncells = 100; // 要处理的细胞数量 9 char fnum[4], fname[128]; // 用于文件名的字符串 10 double celldirs[40], cellspks[40]; // 每个细胞的数据 11 double PD[ncells], plate_out[9]; // 存储细胞的偏好方向 12 13 // 循环处理每个细胞 14 int i; 15 for (i = 1; i <= ncells; i++) { 16 17 // 构造文件名的数字部分 18 if (i < 10) sprintf(fnum, "00%d", i); 19 else if (i < 100) sprintf(fnum, "0%d", i); 20 else sprintf(fnum, "%d", i); 21 22 // 读取方向数据文件 23 sprintf(fname, "data/cell_dirs_%s.txt", fnum); 24 readcell(fname, celldirs, 40, 0); 25 26 // 读取脉冲数据文件 27 sprintf(fname, "data/cell_spks_%s.txt", fnum); 28 readcell(fname, cellspks, 40, 0); 29 30 // 计算偏好方向 31 PD[i-1] = compute_PD(celldirs, cellspks, 40); 32 } 33 34 // 将偏好方向向量打印到屏幕并写入文件 35 show_double_vec(PD, ncells); 36 write_double_vec(PD, ncells, "PDs.txt"); 37 38 return 0; 39}

我们可以看到,在第4行,我们使用#include指令包含了neuron.h头文件(如下所示)。这是一种告诉编译器:这些函数(在neuron.h中)将在稍后完整定义,现在请“信任”它们已正确定义,并具有特定的输入输出形式。

下面是编译该程序的gcc命令,可以看到我们只需将neuron.c加入要传递给编译器的文件列表中。这是编译器实际将neuron.c中的代码整合进我们程序的地方。同时还需要-lm标志,以指示编译器加载标准C数学库(因为neuron.h中包含了#include <math.h>指令)。

1gcc -std=c99 -o go go.c neuron.c -lm

以下是头文件neuron.h的内容:

1#include <stdlib.h> 2#include <stdio.h> 3#include <math.h> 4 5#ifndef M_PI 6#define M_PI 3.1415926535897932384626433832795 7#endif 8 9void show_int_vec(int vec[], int n); 10 11void show_double_vec(double vec[], int n); 12 13void write_double_vec(double vec[], int n, char fname[]); 14 15void showmat(double mat[], int nrows, int ncols); 16 17void readcell(char fname[], double data[], int n, int msg); 18 19void oneplate(double r1, double r2, double a1, double a2, double output[6]); 20 21void platemethod(double a[], double r[], int n, double output[9]); 22 23int mycomp(const void *a, const void *b); 24 25double getangle(double x, double y); 26 27int mycomp_struct(const void *a, const void *b); 28 29double compute_PDr(double celldirs[], double cellspks[], int ntrials, int ndirs); 30 31double compute_PD(double celldirs[], double cellspks[], int ntrials);

我们可以看到,这只是一个列出neuron.c中定义的函数的清单,我们只列出了函数原型,而不是函数的具体实现。当我们写入#include "neuron.h"指令(在go.c的第4行)时,这些函数原型会被加载进来,使得主程序中的代码“知道”这些函数的存在。请注意,这里有一些函数在上面的go.c代码中并未使用。

一个重要的提醒是:只要能查看头文件,我们就掌握了关于源文件中定义了哪些函数及其使用方式的全部信息(包括输入参数和返回值类型)。如果想深入了解这些函数内部实现,可以查看源文件。因此,头文件可以被视为源代码与程序员之间的接口。

要点总结:为了在C程序中包含外部代码(位于独立文件中的代码),我们需要确保以下两点:

  1. 外部C源代码在编译时被传递给编译器。
  2. 我们的C程序能够访问与外部代码相关的函数原型。

另一个简短示例

假设我们想编写一个程序,从命令行接收一个整数输入,并判断该整数是否为质数。现在假设我们不想自己编写判断质数的代码,于是向一位朋友求助,她已编写好此类函数。她发送给我们一对文件(primes.h 和 primes.c)。她的头文件(primes.h)如下:

1int isPrime(int n); // 如果n不是质数则返回0,如果是质数则返回1

从这个头文件中,我们可以得知几点信息:它声明了一个名为isPrime的函数原型。可以看出该函数接受一个整数作为输入参数,并返回一个整数值:若输入为质数则返回1,否则返回0。现在我们已经掌握了使用该函数所需的所有信息(甚至无需查看其源代码,该代码位于primes.c中)。

以下是primes.c文件的内容:

1int isPrime(int n) { 2 3 // 如果不是质数则返回0,如果是质数则返回1 4 5 if (n < 2) return 0; // 最小的质数是2 6 if (n == 2) return 1; // 确保2被识别为质数 7 if ((n % 2) == 0) return 0; // 所有大于2的偶数都不是质数 8 9 int i; 10 for (i = 3; i * i < n; i++) { // 测试到√n为止的整除性 11 if ((n % i) == 0) { 12 return 0; 13 } 14 } 15 return 1; 16}

以下是我们的程序go.c的内容。现在我们可以使用命令 gcc -o go go.c primes.c 进行编译。

No configuration found for ID: a9a35ad9845c153621bb79e51a7b5a408bebee762321b0daa1237d390d21f3e6

以下是运行程序对一组小输入值的结果:

1$ gcc -o go go.c primes.c 2$ ./go 1 3isPrime(1) = 0 4$ ./go 2 5isPrime(2) = 1 6$ ./go 3 7isPrime(3) = 1 8$ ./go 63 9isPrime(63) = 0 10$ ./go 67 11isPrime(67) = 1 12$ ./go 12347 13isPrime(12347) = 1

搜索路径

编译器在查找#include指令包含的头文件时,会根据使用方式在不同位置查找。

  • 如果使用尖括号(如 #include <stdio.h>),编译器会在一系列“默认”的系统级路径中查找,称为搜索路径。
  • 如果使用双引号(如 #include "neuron.h"),编译器会优先在当前文件所在的目录中查找。

可以通过使用 -Idir 编译选项将其他目录添加到搜索路径中,其中 dir 是你要添加的目录。当链接到非标准C库且不在常规系统默认路径中的外部C库时,可能需要这样做。

外部变量

正如我们可以将外部代码包含进C程序一样,我们也可以在C程序中声明一个变量存在于别处(例如在另一个源文件中)。这通过extern关键字实现。

在下面的代码中,变量existing_variable可以在go.c文件中被访问,尽管它最初是在extern.c文件中声明的。这是因为该变量在extern.h头文件中使用extern关键字声明,而extern.h被包含在go.c中。

No configuration found for ID: 648622c15cf2d8b26399e3a42fa2ab21eaa362cc64a4ba782900baecdaef9272

以下是运行该程序的方法:

编译并运行go.c

1$ gcc go.c extern.c -o go 2$ ./go 3Value of existing_variable = 94 4Value of existing_variable = 95

在下一课中,我们将探讨C语言的一个便捷特性——make工具。

宏和C预处理器

GNU make 工具与 Makefiles