C++头文件设计

软件设计的目标

软件设计就是为了完成如下目标,其重要程度依次减低。

  • 实现功能
  • 易于重用
  • 易于理解
  • 没有冗余

对于C++从业者来说,头文件是最能反映其设计思想的,其头文件的设计的合理性规范性及严谨性最能体现从业者的水平。

编译链接

为了将C/C++代码转换为可以在硬件上运行的程序,需要经过编译和链接。(关于编译及链接的简单介绍: CMake搭建项目工程(1)-C/C++编译及CMake那些事)。源文件(.c.cpp.cc)文件经过编译生成目标文件(.o),目标文件会提供三张表,未解决符号表,导出符号表和地址重定向表。

  • 未解决符号表提供在该编译单元里引用但不在本编译单元里实现的符号及其出现的地址。(如源文件a.cc中引用b.cc中实现的函数int add(int, int))
  • 导出符号表提供了本编译单元具有定义,并且提供给其他编译单元使用的符号及其地址。(如b.cc函数int add(int, int),它暴露在b.h中,可以被其他源文件引用)
  • 地址重定向表提供了本编译单元所有对自身地址的引用的记录。

在链接过程中,链接器首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定向表,对其中记录的地址进行重定向(即加上该编译单元实际在可执行文件里的起始地址);然后链接器会遍历目标文件的未解决符号表,在导出符号表里查找需要匹配的符号,并在未解决符号表中所记录的位置上填写实际的地址。

头文件作用

为什么开篇讲述编译链接过程,那是因为未解决符号表导出符号表都与头文件紧密联系,实例化说明,有头文件a.h和其对应的源文件a.cc,
及头文件b.h和对应的源文件b.cc,a.h包含着a.cc要对外暴露的接口(函数或类等)声明,b.h包含着b.cc要对外暴露的接口(函数或类等)声明,对于a.cc编译产生a.o的导出符号表可以认为是a.h里声明的接口的信息,而未解决符号表就是a.cc中引用b.h的函数的信息。链接过程就是在a.o的未解决符号表在b.o中的导出符号表找到并建立链接的过程。(链接又分为静态链接和动态链接)
头文件的作用其实就是对外暴露接口,它像一个功能组件对外提供某些职责。(在JAVA语言里没有头文件的概念,但是其元素、函数的可见性也在一定程度上起到了头文件的作用)

头文件包含的内容

在C++头文件里应该放哪些内容呢?在最开始工作的时候,编写代码更多的是为了实现功能,于是和以前的代码一样,把函数的实现放在源文件里,把函数的声明放在头文件里,完全没有思考头文件的含义。如果一个函数只在自身的源文件中使用,那放在头文件里其实就变相的增大了它的可见性,而如果每个头文件都是如此的话,那整个系统的耦合性非常大。在JAVA语言中,其实也有类似的设计理念,JAVA对可见性不但包含private、protected、public还包括包可见性,这其实就要求我们写代码时,对每个变量每个接口的可见性有很清楚的认知,而不是不明所以的变量全部private,接口全部public。正如作者在工作前几年写C++代码时,把所有的类、函数的声明都放在头文件里一样,这样的头文件设计无疑是糟糕的。

struct VS class

struct是C语言引入的关键字,class是C++引入的关键字,两者都可以包装类型,但存在以下差异:

  • 默认可见性,struct的默认可见性是public,class的默认可见性是private
  • 继承的可见性,struct默认是public继承,而class如果不特别指明默认为private继承
  • class可用于定义模板参数(类似typename),但struct不能

由于在代码设计中需要使用继承,而class需要显示的指明是public继承,存在一定冗余,所以建议仍然使用struct。
我们定义类A、B,希望类C公有继承A和B,对比以下代码,在使用struct时,继承关系非常清爽,而如果使用class,则在每个父类前都需要加上额外的继承可见性,如果在B前面少加了public,则B默认为private,会引入编译错误。

class C : public A, public B{
};
struct C : A, B{
}

C++头文件设计原则

自包含原则

自包含原则是组件不依赖其他组件,能够以独立的方式供外部使用,即:任意一个头文件均可独立编译。如果一个头文件不满足自包含原则,即所有包含该头文件的源文件都需要包含其他的头文件方可通过编译,无疑增加了依赖的复杂性。这项原则的检查非常简单,在写完头文件后,在源文件里只包含该头文件,然后编译如果不报错,则说明满足自包含原则
当然为了满足自包含原则,而在头文件中不加判断的去包含其他的头文件无疑是糟糕的做法,这样任何头文件的变化都会导致连锁的多个源文件的重新编译,会显著增加编译时间。这就引来下一个原则。

优先使用前置声明来减少编译时依赖

在头文件中使用了其他的类或者接口,那需要根据情况决定是前置声明还是包含头文件。对于头文件中只是使用指针、引用、返回值、函数参数的情况,只需要前置声明即可,无需引入头文件。相反地,如果编译器需要知道实体的真正内容时,则必须包含头文件,此依赖也常常称为强编译时依赖。如继承、宏等都需要包含对应的头文件。
如下面代码,由于在该头文件中Cell、CellMap、NeighbourStateTrans都是指针、引用、返回值、函数参数的这些情况,所以无需引入头文件,只需要前置声明即可。这样Cell、CellMap、NeighbourStateTrans类的变化不会引起只包含CellTrans.h的源文件的重新编译,减少了编译时间。

struct Cell;
struct CellMap;
struct NeighbourStateTrans;

struct CellTrans{
    bool oneRoundCellChange(NeighbourStateTrans* trans) const;
    CellMap changeCompelete(Cell c) const;

private:
    bool doCellChange() const;
    virtual CellMap& getCellMap() const = 0;
};
单一职责

头文件职责不单一,依赖不相关元素,则会导致所有包含该头文件的所有实现文件都被这些不相关元素所污染,这也是导致编译时间过长的主要原因。这也是SOLID原则中的单一职责SRP(Single Reponsibility Priciple)在头文件设计时的一个具体运用。头文件职责单一,当然会增加头文件的数量,但这不是问题,每个功能单一的类或者头文件,其实现也会非常简单,其变化方向可控,这样可以使用利用这些功能单一的类或者接口进行组合式设计。如果一个类的职责过多,很容易在需求的演变过程中变为上帝类。

头文件尽量不放置实现

头文件的作用是对外暴露接口,如果将实现代码也放在头文件中(模板实现只能放在头文件里不再此列),则就将自己的实现细节暴露,而这个实现细节是一种不稳定的因素,如果有一天我的内部设计或者数据结构发生变化,则会导致函数实现需要重写,则这样会带来大量文件的重新编译,所以尽量在头文件里不要放置函数实现(inline包括在内)。当然需要注意一些特殊的情况,由于创建其实现文件没有必要,可以将实现放在头文件中。如virtual析构函数、空的virtual函数实现或C++11的default函数等。

禁止头文件循环依赖

头文件的循环依赖带来的问题是牵一发而动全身,任何一个头文件的修改都会引起一大批文件的重新编译,当然出现循环依赖时,更应该去考量自己的设计是否合理,为何会出现如此强的依赖出现。

尽量使用PIMPL设计手法

PIMPL:Private Implementation,是使用指针来隐藏对象的实现细节。也叫编译防火墙。它是GOF的Bridge模式的一种应用,它的出现也是体现了头文件即是接口的真谛。它有以下好处:

  • 降低模块的耦合,隐藏了类的实现
  • 降低编译依赖,提高编译速度
  • 接口与实现分离,提高接口的稳定性
    PIMPL的写法:(在头文件中只暴露X的接口,X包含一个XImpl的私有指针,XIMPL在源文件中定义及实现)
class X {
public:
    /* ... public functions ... */
private:
    class XImpl* pimpl_;  // opaque pointer to forward-declared class
};

在实现层面,有四种操作手法:

  • 1.把所有的private数据(不包含函数)XImpl
  • 2.把所有私有成员(包含函数)放入XImpl;
  • 3.把所有的私有成员和保护成员放入XImpl
  • 4.X只声明公有的接口,将所有的实现都放入XImpl
    最常用的手法为2和4,笔者最喜欢4的方式,但是4的应用场景也受限,需要根据具体设计考量。重点阐述一下手法2,这里说的把所有的私有成员包括函数放入XImpl中并不准确,不能将virtual的功能函数放入到XImpl的类中,这也很好理解。
信息隐藏原则

在头文件中定义类时,public, protected, private的准确区分可以传递设计意图。其中private做为一种实现细节被隐藏起来,为适应未来不明确的变化提供便利。尽量不要将数据设置为public,因为数据可以认为是一种实现方式,如果随着需求的变化,数据结构可能发生变化,则对外暴露的public的数据将是灾难,而应该尽可能的将所有实体设置为私有。外部的类只会调用其public方法,如果只是实现方式发生变化,不会波及其他领域。

头文件宏的唯一性

每一个头文件都应该具有独一无二的保护宏,并保持命名规则的一致性,切记不要太短,在项目较大时,如果头文件保护宏过短,很容易出现重复情况,可以采用_H形式避免。

头文件路径注意大小写敏感

由于在windows下大小写是不敏感的,而在linux下,大小写是敏感的,DEMO和demo是两个完全不同的目录,所以在书写包含路径时大小写一定写正确,来保证代码的可移植性。笔者一般参考JAVA包的方式,所有的路径文件夹采用全小写字母(另外斜杠采用 /)。

利用namespace避免冲突
  • 合理使用C++引入的namespace来避免命名冲突
  • 头文件严禁使用using namespace,污染太大,使用(空间名::xx)方式
  • 在实现文件里使用匿名namespace代替static。

WalkeR-ZG

你可能感兴趣的:(C++头文件设计)