概述
一. 内容
-
假如你对 C++ 程序的某个 class 实现做了轻微修改,仅仅只是修改了 private 接口,然后重新构建这个程序。当你以为只要数秒就能完成的事:毕竟只有一个 class 被修改。但当你按下 build 类似按钮后,你意识到
整个世界都被重新编译和连接了!
发生这样的事,难道你不气恼吗? -
问题出在:
C++ 并没有把接口从实现中分离这件事做的很好
。比如你在头文件定义了一些类成员变量,非指针或者引用,编译器就需要其定义式。而这样的定义式通常由 #include 指示符提供。所以你可能会 #include一大串头文件,不幸的是,这样一来,这些引入的头文件和该头文件便形成了编译依存的关系,当引入的头文件中任何一个被修改,或者这些引入的头文件自身依赖的其他头文件被修改,那么每一个包含该头文件的文件都需要重新编译
。这样的连串编译关系会对许多项目造成难以估计的灾难。 -
你或许奇怪,为什么 C++ 坚持将 class 的实现放在其定义式中。比如你可以使用前置声明,但是注意两个问题:
- 并不是所有的成员都是class,有些可能是 typedefs,templates。
正确的前置比较复杂
。 编译器必须在编译器间知道对象的大小
,前置声明仅仅只能使用 references 或者 pointers。如果是基础类型或者指针,引用没问题,每个编译器都知道有多大,但是对于用户自定义类型的对象,是不存在约定的,它不知道要分配多少空间用以放置一个 custom class,编译器获得这项信息的唯一方式就是询问 class 的定义式。如果 class 不在定义式列出其实现,编译器该如何知道分配多少空间呢?
- 并不是所有的成员都是class,有些可能是 typedefs,templates。
-
采用 pimpl 手法,可以使得 class 的数据成员的实现与定义分离,这是真正的接口与实现分离
。其关键在于以声明的依存性替换了定义的依存性。这揭示了一个本质:尽可能让头文件自我满足,万一做不到,则让它与其他文件内的声明式相依
。其他每一件事都源自这个简单的策略:-
如果使用 object references 或 object pointers 可以完成任务,就不要使用 objects。你可以只靠一个类型声明式
class XXX
就可定义出该类型的 references 和 pointers;但如果定义某类型的 objects,就需要用到该类型的定义式XXX xxx
。 -
如果可以,尽量以 class 声明式替换 class 定义式。注意,当你声明一个函数而它用到某一个 class 时,你并不需要该 class 的定义式,纵使函数以 by value方式传递该参数,返回值亦然。
虽然这种无需定义式的声明函数令人惊奇,但是一旦任何人调用那些函数,还是需要对应的定义式。或许你会奇怪,何必费心声明一个没人调用的函数呢?假设你有一个函数库内含数百个函数声明,客户不太可能调用到每一个,这样你便可将提供 class定义式的义务,从函数声明的头文件,转移到函数被调用的客户头文件,这样便大大降低了编译的依存性。
-
为声明式和定义式提供不同的头文件。一个用于声明式,一个用于定义式,当然这两个文件必须保持一致性,如果某个声明式被改变了,两个文件都得改变。因此程序库客户应该总是 #include 一个声明文件,而非前置声明若干函数。比如在C++标准库中,存在 <iosfwd> 头文件内含 iostream 各组件的声明式,其对应定义分布在若干不同的头文件,包括 <sstream>,<streambuf>,<fstream> 和 <iostream>。对于 template,C++也提供 export 关键字可以让 template 声明式和 template 定义式分割于不同的头文件。不幸的是,支持该关键字的编译器目前非常少。
-
-
我们来看一个 pimpl 手法的例子:
student.h
#pragma once #include <memory> class StudentImpl; class Student { public: Student(int Id); /** 因为此处智能指针使用的是 unique_ptr ,它为了保证高效性, * 其删除器是自身的一部分,它必须保证 raw pointer 为 complete 对象。 * 由于编译器默认生成的析构函数是 inline , * 此时 Impl 所指之物仅仅是前置声明,是一个 non-complete 对象,所以会报错。 * 因此如果使用 unique_ptr 而不是 shared_ptr 实现 Impl时, * 不要使用默认的析构行为,请自行额外实现。 * 因为shared_ptr不使用自身的 deleter,无需这种保证。 */ ~Student(); Student(const Student&) = delete; Student& operator=(const Student&) = delete; Student(Student&&) = delete; Student& operator=(Student&&) = delete; void SetId(int Id) const; int GetId() const; private: std::unique_ptr<StudentImpl> Impl; };
student.cpp
#include "Student.h" class StudentImpl { public: StudentImpl(int mId) { Id = mId; } ~StudentImpl() = default; StudentImpl(const StudentImpl&) = default; StudentImpl& operator=(const StudentImpl&) = default; StudentImpl(StudentImpl&&) = default; StudentImpl& operator=(StudentImpl&&) = default; void SetId(int mId) { Id = mId; } int GetId() const { return Id; } private: int Id; }; Student::Student(int Id): Impl(std::make_unique<StudentImpl>(Id)) {} Student::~Student() = default; void Student::SetId(int Id) const { Impl->SetId(Id); } int Student::GetId() const { return Impl->GetId(); }
main.cpp
const Student Moota(233); std::cout<<Moota.GetId()<<"n"; Moota.SetId(666); std::cout<<Moota.GetId()<<"n";
像 Student 这样使用 Impl 的 classes,往往称为 Handle classes。它们会将所有函数转交给 Impl 实现所有的实际工作。
-
另一个制作 Handle classes 的办法是:令 Student 成为一种特殊的
abstract base class(抽象基类)
,成为 Interface class。这种 class 的目的是详细描述 derived class 的接口,因此它们通常不带成员变量,也没有构造函数,只有一个 virtual 析构函数,以及一组 pure virtual 成员函数,用以描述整个接口。 -
Interface class 类似 Java 和 .NET 的 Interfaces。但
C++ 的 Interface classes 并不需要负担 Java,.NET 的 Interfaces 所需负担的责任
。举个例子,Java 和 NET 都禁止接口内实现其成员变量或者成员函数,但 C++ 并没有禁止这样的行为,这使得我们可以有更大的编程弹性。 -
对于 Interface class 的客户,必须以接口的指针或者引用来编写应用程序。因为不可能针对内含 pure-virtual 的函数的 abstract class 具现出实例。就像 Handle class的客户那样,除非 Interface class 的接口被修改,否则客户不需要重新编译。
-
对于 Interface class,客户通常
使用 factory (工厂)函数或者 virtual 构造函数去创建其对象
,它们会根据不同的参数值,读自文件或者数据库的数据,环境变量等创建不同类型的 derived class对象。 -
实现 Interface class 的两个常见机制:从 Interface class 继承接口,然后实现出接口所覆盖的函数。另一个实现方法涉及多重继承,我们将在条款40进行介绍。
-
Handle class 和 Interface class 解除了接口和实现的耦合关系,从而降低了文件间的编译依存性。但正如你所想的,这需要付出代价,通常也就是
计算机运行中通常要付出的那些:丧失在运行期的速度和增加额外的内存消耗
。在 Handle class 身上,成员函数必须通常 Impl 取得对象数据,这意味着多一层访问的消耗,并且 Impl 的存在需要额外的动态内存分配,这意味着内存分配消耗和释放内存消耗,以及潜在的 bad_alloc 内存不足异常。
而对于 Interface class,由于每一个函数都是 virtual,所以你必须 付出 virtual 实现的代价:额外的虚指针和虚表消耗。
最后,无论Handle classes或 Interface classes,都不能充分利用inline函数,因为它们大多数情况需要隐藏类的实现细节,如函数本体。
当然,如果只因为若干额外的成本就不使用这些技术,将是严重的错误。最好的方式是:
当它们影响运行速度,内存大小过于重大,而类的接口和实现耦合已经显得不再重要时,才考虑使用具体类替换这个行为
。
二. 总结
- 支持编译依存性最小化的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle classes 和 Interface classes。
- 程序库头文件应该以完全且仅声明式(full and declaration-only forms)的形式存在。这种做法不论是否涉及 templates 都适用。
最后
以上就是威武楼房为你收集整理的EffectiveC++-条款31:将文件间的编译依存关系降至最低的全部内容,希望文章能够帮你解决EffectiveC++-条款31:将文件间的编译依存关系降至最低所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复