头文件

在考虑将一个程序分解为若干个源文件的情况下,一般主要考虑的是实际程序中分别来自于不同的单独编译的库,这种情况下,必须考虑定义和声明在这些文件中的共享问题。应尽可能将共享部分集中在一起,这样就只需要一个副本,维护程序时也可以提升可维护性。所谓的头文件,其实它的内容跟源文件中的内容是一样的,都是c的源代码,但头文件一般是不用来进行编译的,只是把把所有的函数声明全部放进一个头文件中,当某一个源文件需要它们时,就可以通过宏命令 “#include”包含进这个源文件中,从而把它们的内容合并到源文件中去,当包含该头文件的源文件被编译时,这些被包含进去的头文件的作用便发挥了。注意,头文件起到的是将程序的不同源文件声明串联起来的作用,与库文件完全不是一个概念(后者是已经编译过的可执行文件)。
实际应用中,通常一方面希望每个源文件只能访问完成任务所需的信息,另一方面维护过多的头文件较为困难。对于中等规模的程序,最好只用一个头文件存放程序中各部分的共享对象,较大程序可能需要使用更多的头文件,需要精心进行组织。

一般的c语言项目工程的头文件应该具有的内容:

  • 宏定义(#define),包括带参和不带参的
  • 结构体、联合体和枚举类型等自定义类型的定义
  • 函数的声明
  • 全局变量的声明(当然全局变量是默认初始化的,定义不定义都差不多)
  • typedef的声明

除了static,inline,extern函数,正常的函数不在.h里实现,只将声明放在头文件里,实现放在源文件里,永远不把外部函数原型(即外部函数声明)放到源文件中。声明或宏定义需要在多个文件中共享时,尤其需要把它们放入头文件中,但如果宏定义或声明为一个源文件私有的(意思是只有该源文件用得到),则最好留在源文件中。

  • 不把定义放在头文件中,因为头文件可能会被程序中的多个文件包含(通过预处理器在包含位置展开),此时发生重定义错误
  • 不把声明放在源文件中,因为要引用的地方可能与定义并不在一个文件中,如果在源文件中声明的话就意味着声明定义不在同一文件,在编译时就无法进行一致性检查(虽然编译阶段没有问题,但实际运行可能有各种问题)

实际开发中一般是将函数和变量的声明放到头文件,再在对应的源文件中#include进来,如果变量的值是固定的,最好使用宏来代替。.h和.c在项目中承担的角色不一样:.c 件主要负责实现,也就是定义函数和变量;.h文件主要负责声明(包括变量声明和函数声明)、宏定义、类型定义等,这些不是c语言语法规定的内容,而是约定成俗的规范,或者说是长期形成的事实标准。
在项目开发中可以将一组相关的变量和函数定义在一个.c文件中,并用一个同名的.h文件(头文件)进行声明,其他模块如果需要使用某个变量或函数,那么引入这个头文件就可以。这样做的另外一个好处是可以保护版权,在发布相关模块之前可以将它们都编译成目标文件或者打包成静态或动态库,只要向用户提供头文件,用户就可以将这些模块链接到自己的程序中(这也是标准库的做法)。

标准头文件和标准库

c语言在发布的时候已经将标准库打包成了静态库,并提供了相应的头文件,如:stdio.h、stdlib.h、string.h等。Linux系统一般将库文件放在/lib和/user/lib目录下,头文件放在/usr/include目录下。很久以前,Linux下的c标准库是libc,不过后来渐渐已经不再维护了,目前的Linux大多以glic(GNU C Library)作为c标准库的实现(GNU C函数库最开始本质上是个第三方库,因为GNU组织是研发编译套件的,它本身就可以附带自己的库以增加功能,方便用户开发,争夺市场份额)。不过现在的不同的Linux发行版对这两个函数库有不同的处理方法,有的可能已经集成在同一个库里了。

以下是在我自己的系统上的测试结果:(两个系统只找到了c++库,难道现在默认没有标准c库了吗?有待考证)

  • 在GNU/Linux上,c++库为GNU的libstdc++
    • 动态库:/usr/lib/gcc/x86_64-linux-gnu/11/libstdc++.so.6
    • 静态库:/usr/lib/gcc/x86_64-linux-gnu/11/libstdc++.a
  • 在MacOS,c++库为LLVM的libc++
    • /usr/lib/libSystem.B.dylib

windows系统我是没有的,据说在windows下标准库由Visual Studio IDE携带,在安装目录下的\VC\include文件夹中会看到头文件,包括常用的stdio.h、stdlib.h等;在\VC\lib文件夹中有.lib文件,是链接器要用到的静态库。

c语言预处理器

c语言通过预处理器提供了一些功能,从概念上讲,预处理是编译过程中单独执行的第一个步骤(宏替换——>预处理——>编译)。两个最常用的预处理命令是#include指令(用于在编译期间把指定文件的内容包含进当前文件中)以及#define指令(用于以任意字符序列替代一个标记)。预处理指令的语法与c语言本体的语法是完全独立的(主要是为了提前把预处理的部分搞完再进行程序编译)。在gcc中其实也有专门的预处理命令生成.i文件:

$gcc -E demo.c -o demo.i -E表示只进行预编译

.i文件也是包含c语言代码的源文件,只不过所有的宏已经被展开,所有包含的文件已经被插入到当前文件中,当你无法判断宏定义是否正确,或者文件包含是否有效时,可以查看.i文件来确定问题。只不过.i实际大体上相当于是比较臃肿的并且加入了一些辅助信息的c语言文件,在开发中大多数情况意义不太大,所以.i文件和上面的gcc命令较少提及用的也不多。

文件包含

文件包含指令,即#include指令可以使处理大量#define指令以及声明更加方便,基本格式为:

#include “文件名” 或 #include<文件名>

形如以上两种形式的行实际上是将被替换为由文件名指定的文件的内容

  • 如果文件名用引号引起来,则在源文件所在的位置(目录)查找该文件
  • 如果在该位置没有找到文件,或者文件名用尖括号括起来,则根据相应的规则查找该文件,这个规则同具体实现有关
    • 在多数情况下,使用尖括号<>编译器会到系统路径(环境变量)下查找头文件,而使用双引号""编译器首先在当前目录下查找头文件,如果没有找到再到系统路径下查找
      • 一般情况下Linux中c/c++头文件路径对应环境变量为C_INCLUDE_PATH或CPLUS_INCLUDE_PATH
      • 静态库和动态库文件路径对应环境变量分别为LIBRARY_PATH和LD_LIBRARY_PATH
    • 使用绝对路径的方式引入头文件时,<>和""没有任何区别,因为头文件路径已经写死了,都是从根部开始查找
  • 虽然文件既可以指定绝对路径,也可以指定相对路径,但是一般情况下都使用相对路径
    • 因为实际开发中一般头文件放在当前工程目录下,这样即使后来改变了工程所在目录,也无需修改包含语句,因为源文件的相对位置没有改变
    • 使用绝对路径的情况一般是环境变量出现问题时难以解决,此时指定绝对路径一定可以找到包含文件
  • 除了默认的环境变量外,用户还可以自定义环境变量或者在编译时指定参数(比如gcc -I指定头文件、gcc -L指定链接的库),从而自己指定搜索路径
    • 虽然可以添加环境变量但其实这种做法并不好,因为这样属于是系统层面上全局的配置,影响所有项目并且增加维护难度,如果非要指定一个路径的话可能写个makefile好点

在UNIX系统中,头文件一般放在目录/usr/include中,被包含的文件本身也可包含#include指令。文件的开始处通常都会有多个#include指令,用以从头文件中包含常见的#define语句和extern声明,以及访问该文件需要的库函数的函数原型声明(严格地说,这些内容没有必要单独存放在文件中,访问头文件的细节与具体实现有关)。
在较大的程序中,#include指令是将所有声明捆绑在一起的较好实现,他保证所有源文件都具有相同的定义与变量声明,可以避免出现不必要的错误。但是另一方面,如果某个包含文件的内容发生了变化,那么所有依赖于该包含文件的源文件都必须重新编译。

宏替换

在c语言中,可以采用命令#define来定义宏,该命令允许把一个名称指定成任何所需的文本,例如一个常量值或者一条语句。在定义了宏之后,无论宏名称出现在源代码的何处,预处理器都会把它用定义时指定的文本替换掉。宏的作用域是从定义位置开始,到其当前所在文件结束或宏对应的#undef指令,即宏定义只属于当前这个文件,其他文件如果没有这个文件就不能使用这个宏定义。如果相同作用域内定义了同名的宏,那么原来的宏也会被覆盖。宏虽然可以在预处理指令中展开,但是并不能用于生成整个预处理指令(因为预处理指令的处理是有先后的,比如至少要在包含进其他头文件以后再依次展开所有的宏定义)。

宏定义的基本格式为:

#define 名字 替换文本

这是最基本的宏替换格式,后续所有出现宏名字记号的地方都将被换为替换文本。#define指令中名字与变量名的命名方式相同,替换文本可以是任何字符串,惯例将宏名称每个字母采用大写,这有助于区分宏与一般的变量。通常情况下#define指令单独占一行,替换文本是#define指令行尾部的所有剩余部分内容。#define指令定义的名字的作用域从其定义点开始,到被编译的源文件末尾处结束。#define指令也可以使用之前定义过的宏,如:

#define TEST1
#define TEST1 TEST2

替换只对整个记号进行,并且对字符串常量(字符串字面量是不同于宏的预处理记号)是不能进行替换的,如:

#define YES ‘1’
YESMAN;printf(“YES”); 其中的YES均不会被进行替换

替换文本可以是任意的,如:

#define forever while(1); 无限循环

宏定义也可以带参数,使之可以对不同的宏调用使用不同的替换文本,带参宏定义的基本格式为:

#define 宏名(形参列表) 替换文本

调用格式为:

宏名(实参列表)

宏的使用很像是函数调用,但宏调用是直接将替换文本插入代码中,形式参数的每次出现都将被替换成对应的实际参数,如:

#define max(A,B) ((A)>(B)?(A):(B)) 宏可以将A,B自动识别为参数
x=max(p+q,r+s); 将被替换为x=((p+q)>(r+s)?(p+q):(r+s));

这种带参宏定义的一个好处在于如果对各种类型参数的处理是一致的,则可将该宏应用于任何数据类型而无需针对不同的数据类型定义不同函数。另外考虑带参宏的展开式,会发现其存在一定的缺陷,其中作为参数的表达式并不是值传递而是拷贝字符序列,所以表达式会执行两次,导致表达式可能存在副作用(如含有自增/自减运算符或输入输出),如:

max(i++,j++)中将对每个参数执行两次自增操作

因此必须要注意适当使用圆括号以保证计算次序的正确性,如:

#define square(x) x*x; 功能存在歧义
square(z+1); 此时宏扩展后的结果为z*z+1=2*z+1!=z*z
正确形式应为:#define square(x) (x)*(x);

注意宏定义没有数据类型只是单纯的替换,所以无法替换数组和指针(如果在参数前上[]或*的话是无法识别宏参数的)。

宏替换在c语言中是十分具有价值的,头文件<stdio.h>中有一个很实用的例子: getchar与putchar函数在实际中常常被定义为宏,可以避免处理字符时经常需要调用函数所需的运行时开销。此外头文件<ctype.h>中定义的函数也常常是通过宏实现的。

可以通过#undef指令取消名字的宏定义,这样可以保证后续调用是函数调用而不是宏调用,宏替换在编译之前,所以有同名宏存在函数是无法调用的,如:

#undef getchar
int gartchar(void){…}

宏定义可以包含两个专用的预处理运算符:#和##,编译器不识别这两个运算符,他们会在预处理时被执行。如果替换文本中参数名以#为前缀,则结果将被扩展为由实际参数替换该参数的的带引号的字符串(#运算符所执行操作可理解为“字符串化”),如:

#define deprint(expr) printf(#expr) 将#与字符串连接运算结合起来编写一个调试打印宏
deprint(“hello\tworld”); 控制台将输出"hello\tworld"而不是hello world
#definr deprint(expr) printf(#expr"=%g",expr)
deprint(x/y); 调用该宏后,宏被扩展为printf(“x/y”"=%g",x/y);等价于printf(“x/y=%g”,x/y);

#运算符实际上等同于将字符串字面值的双引号"替换为\",反斜杠’\‘替换为’\\’,因此替换后的字符串是合法的字符串常量,如:

字符串"hello\tworld"是先转为"“hello\\tworld”“再作为字符串输出,结果为"hello\tworld”

如果替换文本中的参数与##相邻,则该参数将被实参替换,##与前后空白符将被删除,并对替换后的结果进行扫描,这为宏扩展提供了连接实参的手段,如:

#define paste(front,back) front ## back
paste(name,1); 调用该宏后,宏将创建记号name1;

条件包含

可以使用条件语句对预处理本身进行控制,这种条件语句的值是在预处理执行过程中进行运算,这种方式为在编译过程中根据运算所得条件值而选择性地包含不同代码提供了一种手段。
#if语句对其中的常量整型表达式求值

  • 其中不能包含sizeof、类型转换运算符或enum常量,原因主要在于预处理器不检查类型名
  • 虽然条件编译中无法使用sizeof,但在#define中使用sizeof是合法的,因为预处理器并不计算#define语句的表达式,它只是为了原样在程序中展开

若该表达式值为真(非0),则包含其后的各行,直到遇到#endif、#elif或#else语句为止(预处理语句#elif类似于else if)。在#if语句中可以使用表达式defined(名字),该表达式值遵循下列规则:当名字已经定义时,值为1,否则值为0,(defined也是预处理指令)

例1:保证hdr.h文件的内容没有重复包含,可以将该文件内容保存在条件语句中

#if !defined(HDR)       //第一次包含该头文件时,HDR还没有被定义
#define HDR             //进入if分支,定义HDR
//hdr.h文件的内容
#endif                  //如果HDR已被定义,则!defined(HDR)为0,直接跳转到endif处,避免重复包含

对这种情况,c专门定义了两个预处理语句#ifdef和#ifndef,用于测试某个名字是否已经定义,因此上例可改写为例2:

#ifndef HDR
#define HDR
//hdr.h文件的内容
#endif

如果多个头文件能够一致地使用这种结构,那么每个头文件都可以将其所依赖的任何头文件包含进来,用户不必考虑和处理头文件之间的各种依赖关系

例2:测试系统变量SYSTEM,根据该变量的值确定包含哪个版本的头文件

#if SYSTEM==SYSV
#define HDR "sysv.h"
#elif SYSTEM==BSD
#define HDR "bsd.h"
#elif SYSTEM==MSDOS
#define HDR "msdos.h"
#else
#define HDR "default.h"
#endif
#include HDR

预处理器的简要工作流程

预处理过程主要是处理那些源文件和头文件中以#开头的命令,比如 #include、#define、#ifdef等,其规则(以Linux系统为例)一般如下:

  1. 从源代码文件中读取并转换字符,如果必要则将字符转换成源代码指定字符集的字符
    • 如果行尾字符不是换行符时自动添加换行符,宽字符替换成对应单字符
    • 如果添加换行符后存在反斜线符跟着换行符的情况,则将两者都删除(除非在文件的结尾需要以换行符结束)
  2. 将源代码文件分解成若干预处理器标记和空格符序列
    • 删除所有的注释//和/* … */,每个注释都被看作一个空格
  3. 处理所有条件编译命令,比如 #if、#ifdef、#elif、#else、#endif等
  4. 处理#include命令,将被包含文件的内容插入到该命令所在的位置,这与复制粘贴的效果一样
    • 这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件
  5. 删除所有#define,展开宏调用
  6. 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置
  7. 保留所有的#pragma命令,因为编译器需要使用它们
  8. 字符常量和字符串字面量中的字符和转义序列被转换成运行字符集中对应的字符
    • 相邻的字符串字面量被连接为一个字符串
  9. 预处理的结果是生成.i文件

当然只要不影响结果,在编译器具体实现中可以将多个步骤打乱并结合在一起。