-
假如你对C++程序的某个class实现做了轻微修改,仅仅只是修改了private接口,然后重新构建这个程序。当你以为只要数秒就能完成的事:毕竟只有一个class被修改。但当你按下 build 类似按钮后,你意识到整个世界都被重新编译和连接了!发生这样的事,难道你不气恼吗?
-
问题出在:C++并没有把接口从实现中分离这件事做的很好。比如你在头文件定义了一些类成员变量,非指针或者引用,编译器就需要其定义式。而这样的定义式通常由 #include 指示符提供。所以你可能会 #include一大串头文件,不幸的是,这样一来,这些引入的头文件和该头文件便形成了编译依存的关系,当引入的头文件中任何一个被修改,或者这些引入的头文件自身依赖的其他头文件被修改,那么每一个包含该头文件的文件都需要重新编译。这样的连串编译关系会对许多项目造成难以估计的灾难。
-
你或许奇怪,为什么C++坚持将 class 的实现放在其定义式中。比如你可以使用前置声明,但是注意两个问题:
- 并不是所有的成员都是class,有些可能是 typedefs,templates。正确的前置比较复杂。
- 编译器必须在编译器间知道对象的大小,前置声明仅仅只能使用 references 或者 pointers。如果是基础类型或者指针,引用没问题,每个编译器都知道有多大,但是对于用户自定义类型的对象,是不存在约定的,它不知道要分配多少空间用以放置一个 custom class,编译器获得这项信息的唯一方式就是询问 class 的定义式。如果 class 不在定义式列出其实现,编译器该如何知道分配多少空间呢?
-
采用 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);
~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函数,因为它们大多数情况需要隐藏类的实现细节,如函数本体。
当然,如果只因为若干额外的成本就不使用这些技术,将是严重的错误。最好的方式是:当它们影响运行速度,内存大小过于重大,而类的接口和实现耦合已经显得不再重要时,才考虑使用具体类替换这个行为。