模块的封装(四)——头文件的作用

原文地址:https://www.amobbs.com/thread-5675247-1-1.html

认真说起来,头文件(Header File)是个短命的家伙——就整个编译过程来说,它的寿命是最短的。
为什么这么说呢?关于头文件的话题,讨论起来那可是“孩子没娘,说来话长了”,既然是闲聊、你也不是等着这篇文章救命,那就不妨从头开始说起——先假设读者们都是不了解编译基本过程的初学者。
一个编译(Compilation)过程通常至少分为三个阶段:预编译(Precompiling)、编译(Make)和链接(Linking)。他们就像一个流水线一环套一环——前一工序的输出是后一工序的输入。这本没有什么稀奇的,但对于程序员来说,这个过程中有几个基本常识是需要记住的:

  1. C语言编译的基本单位(Compilation Unit)是 C源文件 (而并没有头文件);
  2. 同一个工程中,不同C源文件的编译是彼此独立的(毫不相干的);
  3. 头文件在预编译阶段就已经合并到对应的C源文件中了,和所有的宏以及条件编译一样,到了编译阶段,所有的头文件、宏都是不存在的,已经被替换为对应的内容和常量了。

理解这三点,基本上已经可以解决很多我们日常编码过程中存在的很多疑问,比如:

  • Q1:为什么不能C语言头文件里面定义变量或者函数的实体?

  • Q2:为什么有的时候宏的先后顺序并不那么重要?

  • Q3:为什么可以在源代码的任意位置(另起一行后)定义宏,甚至是include别的头文件?
    推荐大家基于前面的三个事实自己思考。

    头文件里可以放什么呢?这是个值得讨论的问题:

  • 各类宏

  • 函数的声明(也就是 extern xxxxx)

  • 全局变量的声明(也就是 extern xxxx)
    然而,值得说明的是,这里有一个编码规则值得你去遵守:头文件里坚决不要放全局变量有关的任何东西(硬要加,也必须是const类型的,比如各类接口)。

  • 类型定义(typedef, struct, union 之类的)

  • static 的变量实体和函数实体。
    这个可以有,为啥呢?因为即便多个c源文件包含同一个头文件导致同样的函数和变量实体存在多份,但static 的另外一个名字 “private” 可以保证每一份变量和函数实体都是彼此独立的,都是每个c源代码的私人财产——你可以有,我也可以有。“哎?你也有啊,真巧哎,我也有……”

  • inline 的函数
    这个和static是一个道理。

头文件里面不能放函数的实体,想必原因大部分人都知道了,这里就不再赘述。但头文件里不放(非const)的全局变量的声明,这怎么玩?这里需要说明一下,头文件里不是不能放(非const)的全局变量声明,而是我提供了一个人为的规定(规范),建议不要放任何(非const)的全局变量到头文件里,具体原因和解决方案,我们在别的帖子里再讨论(其实有人讨论过,大约就是,如何避免使用全局变量)——是的,避免使用(非const)的全局变量是可以做到的——这里也不再赘述。说了这么多废话,我们真正要讨论的内容还没有开始:
如何建立头文件的使用规则,使其即灵活、使用方便,又灵活且便于扩展(模块化)——符合面向接口开发的要求,方便我们 建立黑盒子?
简而言之,如何让头文件的使用不再头疼;永远告别循环包含;方便代码的移植?

首先,思考一个简单的问题?为什么我们要用头文件?答案其实很简单,因为每个.c文件都是独立编译的,因此需要在源代码级别传递一些信息,类似一群人在唠嗑:

源代码A:              我定义了一个函数,你们哥几个要用么?
源代码B和源代码C: 我们要用啊,函数原型(prototype)什么样子啊?
源代码A:               你们不用费脑经记(抄下来),我都写好了,放在一个头文件里了,你们直接include就可以了。
源代码B和源代码C: 这个敢情方便。那你头文件放哪里了?
源代码A:               有两种方式,要么你直接到我这里来拿(指定路径);要么你找编译器问(编译器指定搜索路径)。
源代码D:               你们整这么麻烦做什么?你直接告诉我原型,我抄下来,不就不用问这个问那个,还包含文件什么的,真麻烦。
源代码A:               D啊,你老想耍小聪明,万一我更新了你不知道怎么办?我有义务告诉你么?并没有。
源代码B和源代码C: 是啊,是啊,A以后估计要外包了,不在这里了,到时候有变化,都记录在头文件里,你本地放一个,没法及时同步的。
源代码D:              我不听!我不听!我不听……

是不是很有画面感?抛开捂着耳朵的D,我们回到讨论的话题——既然头文件是用来交换信息的,那么如果把所有的信息都放在一起,大家需要的时候各取所需,岂不美哉?——基于这种思想,几乎所有人都见过把所有变量、函数、宏、类型定义都放到一个叫做system.h的头文件里的做法。你有这么做过么?不要不好意思,几乎所有人都这么做过——因为实在太方便了,世界大同,挺好,直到你尝试和别人一起合作开发系统,并试图在不同项目间复用一些代码的时候:

“何首乌藤和木莲藤缠络着”……对于这种情况,我们叫做耦合。“是要找个时间来理一理了”,你对自己说,然后长叹了一口气,发现这句话其实很早之前就说过了。想到还有更奇葩的循环包涵的问题,你不得不感叹,头文件真的是个头疼的东西——要不我们还是不用了吧?直接抄下来貌似更简单啊——源程序D痴痴的笑了。

那么,如何解决这个问题呢?其实,从实践经验来看,头文件的用途分为两大类:

站在C源文件的视角上:

从外部向C源文件内部 输入配置信息——我们把这类头文件叫做配置头文件(Configuration Header File)。需要强调的是,信息的流动方向是 从外向内,所以又可以简单的理解为输入性的头文件(Header File for information input)。常见的app_cfg.h 就是典型的配置头文件。

从 C源文件内部向外 输出接口信息(全局函数、类型,宏定义等信息)——我们把这类头文件叫做接口头文件(Interface Header File)。需要强调的是,信息的流动方向是 从内向外,所以又可以简单的理解为输出性的头文件(Header File for information output)。常见的, spi.husart.h, device.h, stdint.h 就是典型的接口头文件。

输入和输出两个不同的职能如果被放在同一个头文件里,就有极大的风险产生循环包含或者交叉引用(两个相反方向的箭头产生闭合的圆圈)。
system.h实际上就是一个混淆信息流动方向的例子。这就是本质上依赖system.h的工程 模块不好拆分的原因。一般来说,为了“降低”循环包含的风险,同时又为了尊重常见模块封装的习惯,我们会人为的规定:

  • 模块内部的各类文件“允许”包含模块的接口头文件;
  • 模块内部的各类文件“应该”包含模块自己的配置头文件;
  • 除极少数情况外,系统中所有的配置头文件都“应该避免”包含任何街口头文件。

简单的来说,这三条规则就是允许两个信息流单向的进行混合:也就是,配置头文件的信息可以单向的流向接口头文件;但反过来却绝对禁止,这就从源头上极大的降低了发生“循环包含”的概率。但即便如此,还有另外一类问题单纯依靠拆分头文件是不能解决的,这就是头文件的“交叉引用”问题。

模块的封装(一):C语言类的封装
模块的封装(二):C语言类的继承和派生
模块的封装(三):无伤大雅的形式主义

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章