如何防止不必要的源文件编译






3.90/5 (13投票s)
本文展示了如何通过设计 C++ 类来提高文件间的独立性。
引言
我最近在做一个项目时,觉得非常恼火。项目越来越大,编译时间也飞速增长。我正在修改一个类,仅仅对一个类做了小小调整,却发现整个项目都要重新编译。为了说明这个问题,我举了以下例子:
class A
{
public:
void foo();
private:
AMember m_member;
}
声明这个类的头文件被包含在预编译头文件中。关于它是否应该在这里,无需争论,重要的是它带来的后果。如果我们修改了类 A
本身,项目中的每个 .cpp 文件都会重新编译。这是有道理的,因为头文件被包含在预编译头文件中,所以所有 .cpp 文件都可能在使用 A
。但是,如果我们修改了 AMember
,所有文件也会重新编译!我们不希望这样,对吧?如果只有 AMember
的源文件被重新编译,是不是更合理呢?我认为是。因此,在本文中我将向您展示如何让编译器清楚地知道,它只编译最少量的代码来反映这些更改。
编译器为何会这样做?
问题在于类规范包含了实现细节(即 m_member
)。编译器需要知道 AMember
的类型,因此你经常会在头文件顶部看到类似 #include "AMember.hpp"
的代码。但这会产生编译依赖。所以,我们希望达到的目标是能够移除 '#include
' 指令。要做到这一点,我们需要有效地将所有实现细节与规范分离,并将实现细节包含在类的实际实现中。
有关详细解释,我建议您阅读 Scott Meyers 的著作 Effective C++。他花了相当多的篇幅专门讨论这个问题。
Scott Meyers 的两种解决方案
因此,在遇到这个问题时,我回想起曾经读过关于这个问题的内容。经过一番搜索,我找到了 Scott Meyers 的书 Effective C++,其中就讨论了这个问题。书中,他为我们提供了两种解决这个问题的方法。然而,这两种方法都有一些小小的缺点。因为我的解决方案基于 Scott Meyers 的这两种方法,我将首先解释他是如何为这个问题创建一种变通方法的。
使用句柄类
他给出的第一个解决方案是使用 **句柄类**。句柄类只不过是一个类的接口,它有一个指向其成员函数实际实现的成员变量。这是如何工作的?我将向您展示。
// We can declare an empty shell of this class and the compiler
// will accept it simply because we only use a pointer to this
// class.
class AImpl;
class A
{
public:
void foo();
private:
AImpl * impl;
};
#include "A.hpp"
// This class contains the actual implementation
class AImpl
{
public:
void foo();
private:
AMember m_member;
};
#include "AImpl.hpp"
void A::foo()
{
impl->foo();
}
所以我们所做的就是包含 A.hpp。构造函数将分配 impl
对象(为方便阅读,我省略了这部分)。我们不需要包含 AImpl
的头文件,因为我们在头文件顶部声明了一个空类 AImpl
。这是可行的,因为我们只使用一个指向实际类的指针。在 A
的源文件中,我们确实需要包含 AImpl
的头文件,但这正是我们想要的。类 A
的成员函数只是链接到 AImpl
的成员函数。这种实现的缺点是:
- 每个对象需要一个额外的指针。
- 成员函数需要在运行时重新链接。
- 需要为实现对象动态分配和释放内存。
使用协议类
他给出的第二个解决方案是使用 **协议类**。协议类是一个 抽象
类,因此它只代表实际类的规范。以下是如何使用协议类的示例:
class A
{
public:
virtual void foo() = 0;
// We need a way to construct a class of this type. Because
// it's abstract we can't instantiate it directly.
static A * makeA();
};
#include "A.hpp"
class AImpl : public A
{
public:
void foo();
private:
AMember m_member;
};
#include "AImpl.hpp"
A * A::makeA()
{
return new AImpl;
}
同样,你只需要包含 A.hpp。但是,这次我们面临的是一个 抽象
类,它不能直接实例化。因此,我们需要一个辅助函数来构造类 A
的一个子类(在本例中是 AImpl
)并返回其指针。如你所见,我们已经完全将实现细节从 A.hpp 中移除了,所以它效果非常好。这种实现的缺点是:
- 需要使用辅助函数来构造对象。
- 需要手动释放辅助函数分配的指针。
- 我们被迫使用指针,所以需要编写 '
->
' 而不是 '.
'(当然,你可以创建一个引用,但这也会增加工作量)。 - 我们有
虚
函数,这意味着我们有一个虚
表,这会增加额外的开销。 虚
函数有运行时链接。
虽然这两个示例都能完美工作,但我一直在想是否能想出一个效果同样好,但没有这些缺点的解决方案。现在,我们来谈谈我最喜欢的 C++ 主题,模板!
使用模板
请注意,我的解决方案代码中存在一个严重的 bug。我正在努力解决它。Scott Meyers 提出的两种解决方案是正确的。有关 bug 的更多信息,请阅读下面的帖子。不过,我已经有了另一个解决方案,下面将对此进行描述。我保留这部分文章只是为了说明。
我从来都不太喜欢 MFC,当我寻找替代品时,我找到了 ATL/WTL。这让我接触到了模板的概念,从那时起我就沉迷于使用它们。那么,如何使用模板将实现细节与规范头文件分离呢?我将向您展示:
template <class T>
class TA
{
public:
void foo();
};
// Again we declare an empty implementation class
class AImpl;
// We use this typedef so we can instantiate objects of type TA using A
typedef TA<AImpl> A;
#include "A.hpp"
class AImpl : public TA<AImpl>
{
public:
void foo();
private
AMember m_member;
};
#include "AImpl.hpp"
void TA<AImpl>::foo()
{
(static_cast<AImpl *>(this))->foo();
}
如果你对模板不太熟悉,这可能看起来有点不寻常。我建议你阅读本网站上一个很棒的教程,它足以向普通人解释模板的用法。我所做的就是利用这样一个事实:我已经知道类 T
将会有值 AImpl
。实际上,这只是唯一可以编译这个源文件的值,因为我只为这个特定情况提供了实现。这会立即增加一层保护,因此不可能意外地实例化例如 TA<BImpl>
。在 TA
的实现中,我将 this
指针转换为 AImpl
指针。我们甚至可以使用 static_cast
,因为我们确信这是一个合法的转换。因为现在我们有一个指向 AImpl
类的指针,我们可以调用它的成员函数!很简单,不是吗?但我不会像 Scott Meyers 提供的解决方案那样拥有同样的缺点,我言出必行?
模板解决方案的优势
让我们再次总结一下其他解决方案的缺点,并将它们与我的解决方案进行比较。
- Scott Meyers 的第一个解决方案
- 每个对象需要一个额外的指针。
我的解决方案不需要任何额外的变量。
- 函数在运行时重新链接。
在我的解决方案中,编译器在编译时就知道需要调用哪些函数。这意味着可以进行编译器优化!
- 需要动态分配和释放内存。
我的解决方案不需要内存分配或释放。
- 每个对象需要一个额外的指针。
- Scott Meyers 的第二个解决方案
- 需要一个辅助函数来实例化对象。
在我的解决方案中,你可以使用
typedef
,或者如果你不喜欢这样,你可以通过使用TA<AImpl>
来实例化。 - 对象需要手动释放。
我的解决方案可以实例化,所以根据你的实例化方式,你不需要手动释放它。
- 因为辅助函数返回一个指针,所以你被迫使用指针。
你可以将模板实例化为指针或“正常”方式,不强迫你使用指针。
- 具有虚表的开销。
我的实现不使用虚函数,因此没有虚表。
- 函数在运行时重新链接。
正如我在上面几行所说,在我的解决方案中,函数在编译时链接,这意味着可以进行优化。
- 需要一个辅助函数来实例化对象。
所以看起来我确实信守了承诺。它也能正常工作!如果你仍然不完全理解我是如何做到的,只需查看提供的示例。在示例中,我演示了上述所有技术以及最初导致所有源文件重新编译的情况。请注意,在演示中,无论哪种方式编译都不会花费很长时间,但可以想象一下在大项目上的速度提升。
模板解决方案的缺点
- 告别编译时的咖啡休息时间!
- 有 bug。。。 :-O
一个更简单的解决方案
由于有几个人向我指出了代码中的一个严重 bug(现在我已经有了变通方法,但你可能会不喜欢这些解决方案),我不得不考虑替代方案。这是针对一个特定但非常常见且有用的情况的解决方案(这导致了 bug)。假设你有以下类:
class A
{
public:
AMember m_member;
};
依我看,这是糟糕的面向对象编程,因为你不应该有 public
成员变量。如果你绝对想要这样做,那么确实没有其他办法,只能使用指针,这与 Scott Meyers 的第一个解决方案很像。
如果你同意我的看法,认为这是糟糕的类设计,那么你一定更喜欢以下设计:
class A
{
public:
const AMember& GetMember();
private:
AMember m_member;
};
GetMember()
函数只是返回一个对 private
成员的引用。嗯,这是我们可以处理的东西。我给你带来我最后一个也是最简单(也是最有效)的示例:
// This type of relation doesn't require
// the compiler to know the specification of the class
class AMember;
class A
{
public:
const AMember& GetMember();
};
#include "A.hpp"
#include "AMember.hpp"
const AMember& A::GetMember()
{
static AMember member;
return member;
}
这样可以吗?是的,可以。它有像我的另一个示例那样恼人的 bug 吗?几乎不可能有,因为它太简单了。 :-) 如果你想使用 AMember
类,你只需要包含相应的头文件并调用 A::GetMember()
。这样,你就迫使自己进行良好的面向对象编程,并且减少了依赖!缺点是,你只能有一个类 A
的实例,因为它使用了 static
变量。在很多情况下,这并不重要,特别是对于那些存在依赖关系问题的类,因为它们通常是全局单例类。
结论
我向你展示了几种可以用来提高类之间编译器独立性的技术。这些技术(除了模板那个尚存 bug 的)没有一种是错误的,它们都有不同的缺点。你必须为每种情况选择合适的技术。也没有必要将项目中的每个类都更改为这些技术之一,但对文件结构中的关键点进行一些更改可以大大缩短编译时间。