将文件间的编译依存关系降至最低——条款31

2023-11-07

        假设你对C++程序的某个class实现文件做了些轻微修改。注意,修改的不是class接口,而是实现,而且只改private成分。然后重新建置这个程序,并预计只花数秒就好。毕竟只有一个class被修改。你按下“Build”按钮或键入make(或其它类似命令),然后大吃一惊,然后感到窘困,因为你意识到整个世界都被重新编译和连接了!当这种事情发生,难道你不气恼吗?

        问题出在C++并没有把“将接口从实现中分离”这事做得很好。Class的定义式不只详细叙述了class接口,还包括十足的实现细目。例如:

class Person {
public:
	Person(const std::string& name, const Date& birthday, const Address& addr);
	std::string name() const;
	std::string birthDate() const;
	std::string address() const;
	...
private:
	std::string theName;           // 实现细目
	Date theBirthDate;             // 实现细目
	Address theAddress;            // 实现细目
};

        这里的class Person无法通过编译——如果编译器没有取得其实现代码所用到的classes string,Date和Address的定义式。这样的定义式通常由#include指示符提供,所以Person定义文件的最上方很可能存在这样的东西:

#include <string>

#include "date.h"

#include "address.h"

        不幸的是,这么一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个含入Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。

        你或许会奇怪,为什么C++坚持将class的实现细目置于class定义式中?为什么不这样定义Person,将实现细目分开叙述?

namespace std {
	class string;    // 前置声明(不正确,详下)
}
class Date;          // 前置声明
class Address;       // 前置声明
class Person {
public:
	Person(const std::string& name, const Date& birthday, const Address& addr);
	std::string name() const;
	std::string birthDate() const;
	std::string address() const;
	...
};

        如果可以那么做,Person的客户就只需要在Person接口被修改过时才重新编译。

        这个想法存在两个问题:

        第一,string不是个class,它是个typedef(定义为basic_string<char>)。因此上述针对string而做的前置声明并不正确;正确的前置声明比较复杂,因为涉及额外的templates。然而那并不要紧,因为你本来就不该尝试手工声明一部分标准程序库。你应该仅仅使用适当的#includes完成目的。标准头文件不太可能成为编译瓶颈,特别是如果你的建置环境允许你使用预编译头文件。如果解析标准头文件真的是个问题,你可能需要改变你的接口设计,避免使用标准程序库中“引发不受欢迎之#includes”那一部分。

       第二: 关于“前置声明每一件东西”的另一个(同时也是比较重要的)困难是,编译器必须在编译期间知道对象的大小。考虑这个:

int main()
{
	int x;               // 定义一个int 
	Person p(params);    // 定义一个Person
	...
}

        当编译器看到x的定义式,它知道必须分配多少内存(通常位于stack内)才够持有一个int。没问题,每个编译器都知道一个int有多大。当编译器看到p的定义式,它也知道必须分配足够空间以放置一个Person,但它如何知道一个Person对象多大呢?编译器获得这项信息的唯一办法就询问class定义式。然而如果class定义式可以合法地不列出实现细目,编译器如何知道该分配多少空间?

        此问题在Smalltalk,Java等语言上并不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说它们将上述代码视同这样:

int main()
{
    int x;               // 定义一个int 
	Person *p;           // 定义一个指针指向Person对象
	...
}

        这当然也是合法的C++代码,所以你也可以自己玩玩“将对象实现细目隐藏于一个指针背后”的游戏。针对Person我们也可以这样做:把Person分割为两个classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class取名为PersonImpl,Person将定义如下:

#include <string>
#include <memory>    // 此乃为了tr1::shared_ptr而含入

class PersonImpl;    // Person实现类的前置声明
class Date;          // Person接口用到的classes的前置声明 
class Address;       
class Person {
public:
	Person(const std::string$ name, const Date& birthday, const Address& addr);
	std::string name() const;
	std::string birthDate() const;
	std::string address() const;
	...
private:
	std::tr1::shared_ptr<PersonImpl> pImpl;  // 指针,指向实现物:std::tr1::shared_ptr 见条款13
};

        在这里,main class(Person)只内含一个指针成员(这里使用tr1::shared_ptr,见条款13),指向其实现类(PersonImpl)。这般设计常被称为pimpi idiom(pimpl是“point to implementation”的缩写)。这种classes内的指针名称往往就是pImpl,就像上面代码那样。

        这样的设计下,Person的客户就完全与Dates,Addresses以及Persons的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。这真正是“接口与实现分离”!

        这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其它文件内的声明式(而非定义式)相依。其它每件事都源自于这个简单的设计策略:

  • 如果使用object references或object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。
  • 如果可以,尽量以class声明式替换class定义式。注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value方式传递该类型的参数(或返回值)亦然:
class Date;                      // class声明式 
Date today();                    // 没问题,这里并不需要
void clearAppointments(Date d);  // Date的定义式

        当然,pass-by-value一般而言是个糟糕的主意(见条款20),但如果你发现因为某种因素被迫使用它,并不能够就此为“非必要之编译依存关系”导入正当性。

        声明today函数和clearAppointments函数而无需定义Date,这种能力可能会令你惊讶,但它并不是真的那么神奇。一旦任何人调用那些函数,调用之前Date定义式一定得先曝光才行。

  • 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。

        像Person这样使用pimpl idiom的classes,往往被称为Handles classes。也许你会纳闷,这样的classes如何真正做点事情。办法之一是将它们的所有函数转交给相应的实现类(implementation classes)并有后者完成实际工作。例如下面是Person两个成员函数的实现:

#include "Person.h"       // 我们正在实现Person class,所以必须#include其class定义式
#include "PersonImpl.h"   // 我们也必须#include PersonImpl的class定义式,否则无法调用其成员函数;
                          // 注意,PersonImpl有着和Person完全相同的成员函数,两者接口完全相同。
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
	:pImpl(new PersonImpl(name, birthday, addr))
{}
std::string Person::name() const
{
	return pImpl->name();
}

        请注意,Person构造函数以new(见条款16)调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name。这是重要的,让Person变成一个Handle class并不会改变它做的事,只会改变它做事的方法。

        另一个制作Handle class的办法是,令Person成为一种特殊的abstract base class(抽象基类),称为Interface class。

        在Handle classes身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer的大小。最后,implementation pointer必须初始化(在Handle class构造函数内),指向一个动态分配得来的implementation object,所以你将蒙受因动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性。

        至于Interface classes,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本(见条款7)。此外Interface class派生的对象必须内含一个vptr(virtual table pointer),这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface class之外是否还有其他virtual函数来源。

        最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为。条款30解释过为什么函数本体为了被inlined必须(很典型地)置于头文件内,但Handle classes和Interface classes正是特别被设计用来隐藏实现细节,如函数本体。

请记住

  • 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
  • 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。

个人观点:该条款描述的内容挺多,用了很多实例来论证,刚开始阅读时不太理解,反复看了4遍才感觉理解其中的观点。其中的核心思想是为了降低文件编译依存关系,尽量以指针来代替类定义。在实际项目开发中,一般情况下都很少在意这种情况,改动之后编译时间长一点都能接受,不过开发时尽量避免在.h文件中include大量的其它文件,最好能用@class。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

将文件间的编译依存关系降至最低——条款31 的相关文章

  • Effective C++

    条款01 视C 为一个语言联邦 将 视为一个由相关语言组成的联邦而非单一语言 条款02 尽量以const enum inline替换 define define处理与预处理阶段 而非编译阶段 因此此条款也可称为 宁可以编译器替换预处理器比较
  • 区分接口继承和实现继承——条款34

    表面上直截了当的public继承概念 经过严密的检查之后 发现它由两部分组成 函数接口 function interfaces 继承和函数实现 function implementations 继承 这两种继承的差异 很像本书导读所讨论的函
  • 确定你的public继承塑模出is-a关系——条款32

    如果你令class D Derived 以public形式继承class B Base 你便是告诉C 编译器 以及你的代码读者 说 每一个类型为D的对象同时也是一个类型为B的对象 反之不成立 你的意识是B比D表现出更一般化的概念 而D比B表
  • 运用成员函数模板接受所有兼容类型——条款45

    所谓智能指针是 行为像指针 的对象 并提供指针没有的机能 例如条款13曾经提及std auto ptr和tr1 shared ptr如何能够被用来在正确时机自动删除heap based资源 STL容器的迭代器几乎总是智能指针 无疑地你不会奢
  • 《Effective C++》学习笔记——区分接口继承和实现继承

    派生类public继承自基类 其中函数均是接口继承 实现继承又分为缺省继承与强制继承 对应着虚函数与非虚函数 我们在做项目时 对于任何事物都要抱有先描述再组织的心态 因此 当描述事物为其构建框架尤其是存在继承 is a 关系时 一定要搞清楚
  • 令operator=返回一个reference to *this——条款10

    关于赋值 有趣的是你可以把它们写成连锁形式 int x y z x y z 15 同样有趣的是 赋值采用右结合律 所以上述连锁赋值被解析为 x y z 15 这里15先被赋值给z 然后其结果 更新后的z 再被赋值给y 然后其结果 更新后的y
  • 尽量以const、enum、inline替换 #define——条款02

    这个条款或许改为 宁可以编译器替换预处理器 比较好 因为或许 define 不能被视为语言的一部分 一 比如定义一个宏 define ASPECT RATIO 1 653 这个ASPECT RATIO也许从未被编译器看见 也许在编译器开始处
  • 将文件间的编译依存关系降至最低——条款31

    假设你对C 程序的某个class实现文件做了些轻微修改 注意 修改的不是class接口 而是实现 而且只改private成分 然后重新建置这个程序 并预计只花数秒就好 毕竟只有一个class被修改 你按下 Build 按钮或键入make 或
  • 条款13: 以对象管理资源

    结论 为防止资源泄漏 请使用RAII对象 它们在构造函数中获得资源并在析构函数中释放资源 两个常被使用的RAII classes分别是tr1 share ptr和auto ptr 前者通常是较佳选择 因为其copy行为比较直观 若选择aut
  • Effective C++改善程序与设计的55个具体做法笔记

    Scott Meyers大师Effective三部曲 Effective C More Effective C Effective STL 这三本书出版已很多年 后来又出版了Effective Modern C More Effective
  • 《Effective C++》 全书内容提炼总结

    个人博客地址 https cxx001 gitee io 本文阅读说明 孔子云 取乎其上 得乎其中 取乎其中 得乎其下 取乎其下 则无所得矣 对于读书求知而言 这句古训教我们去读好书 最好是好书中的上品 经典书 Effective C 就是
  • Effective C++ 条款十二:复制对象时勿忘其每一个成分

    这句话包含两部分的意思 第一部分是要考虑到所有成员变量 特别是后加入的 相应的拷贝构造函数和赋值运算符要及时更新 第二部分是在存在继承时 不要遗忘基类部分的复制 先看第一部分的意思 举个例子 class SampleClass privat
  • Effective C++ 学习笔记 《六》

    Item 6 Explicitly disallow the use of compiler generated functions you do not want 其实这一节的内容是和item5紧密相连的 上一节的核心围绕着编译器会自动生
  • 将与参数无关的代码抽离templates——条款44

    Templates是节省时间和避免代码重复的一个奇方妙法 不再需要键入20个类似的classes而每一个带有15个成员函数 你只需键入一个class template 留给编译器去具现化那20个你需要的相关classes和300个函数 cl
  • 考虑virtual函数以外的其他选择——条款35

    假设你正在写一个视频游戏软件 你打算为游戏内的人物设计一个继承体系 你的游戏术语暴力砍杀类型 剧中人物被伤害或因其他因素而降低健康状态的情况并不罕见 你因此决定提供一个成员函数healthValue 它会返回一个整数 表示人物的健康程度 由
  • 写了placement new也要写placement delete——条款52

    placement new和placement delete并非C 兽栏中最常见的动物 如果你不熟悉它们 不要感到挫折或忧虑 回忆条款16和17 当你写一个new表达式像这样 Widget pw new Widget 共有两个函数被调用 一
  • 明智而审慎地使用多重继承——条款40

    当多重继承 multiple inheritance MI 进入设计景框 程序有可能从一个以上的base classes继承相同名称 如函数 typedef等等 那会导致较多的歧义机会 例如 class BorrowableItem 图书馆
  • 掌握 Effective C++ : 条款01

    背景 Effective C 是每个 C 程序员都应该读的经典之作 书中涵盖了 C 编程中的一系列最佳实践 包括了面向对象设计 模板 STL 异常处理等方面的内容 由于 C 的发展非常迅速 书中的某些内容可能已经过时 但依然是值得好好学习的
  • 复制对象时勿忘其每一个成分——条款12

    设计良好之面向对象系统 OO systems 会将对象的内部封装起来 只留两个函数负责对象拷贝 复制 那便是带着适切名称的copy构造函数和copy assignment操作符 我称它们为copying函数 条款5观察到编译器会在必要的时候
  • Effective C++——尽可能使用const

    const允许指定一个语义约束 也就是指定一个 不该被改动 的对象 而编译器会强制实施这项约束 只要保持某个值不变是事实 就应该说出来 以获得编译器的协助 保证不被违反 const与指针 注意const的写法 const char p p可

随机推荐