65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.90/5 (13投票s)

2004年2月7日

CPOL

9分钟阅读

viewsIcon

87431

downloadIcon

274

本文展示了如何通过设计 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 的成员函数。这种实现的缺点是:

  1. 每个对象需要一个额外的指针。
  2. 成员函数需要在运行时重新链接。
  3. 需要为实现对象动态分配和释放内存。

使用协议类

他给出的第二个解决方案是使用 **协议类**。协议类是一个 抽象 类,因此它只代表实际类的规范。以下是如何使用协议类的示例:

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 中移除了,所以它效果非常好。这种实现的缺点是:

  1. 需要使用辅助函数来构造对象。
  2. 需要手动释放辅助函数分配的指针。
  3. 我们被迫使用指针,所以需要编写 '->' 而不是 '.'(当然,你可以创建一个引用,但这也会增加工作量)。
  4. 我们有 函数,这意味着我们有一个 表,这会增加额外的开销。
  5. 函数有运行时链接。

虽然这两个示例都能完美工作,但我一直在想是否能想出一个效果同样好,但没有这些缺点的解决方案。现在,我们来谈谈我最喜欢的 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 的第一个解决方案
    1. 每个对象需要一个额外的指针。

      我的解决方案不需要任何额外的变量。

    2. 函数在运行时重新链接。

      在我的解决方案中,编译器在编译时就知道需要调用哪些函数。这意味着可以进行编译器优化!

    3. 需要动态分配和释放内存。

      我的解决方案不需要内存分配或释放。

  • Scott Meyers 的第二个解决方案
    1. 需要一个辅助函数来实例化对象。

      在我的解决方案中,你可以使用 typedef,或者如果你不喜欢这样,你可以通过使用 TA<AImpl> 来实例化。

    2. 对象需要手动释放。

      我的解决方案可以实例化,所以根据你的实例化方式,你不需要手动释放它。

    3. 因为辅助函数返回一个指针,所以你被迫使用指针。

      你可以将模板实例化为指针或“正常”方式,不强迫你使用指针。

    4. 具有虚表的开销。

      我的实现不使用虚函数,因此没有虚表。

    5. 函数在运行时重新链接。

      正如我在上面几行所说,在我的解决方案中,函数在编译时链接,这意味着可以进行优化。

所以看起来我确实信守了承诺。它也能正常工作!如果你仍然不完全理解我是如何做到的,只需查看提供的示例。在示例中,我演示了上述所有技术以及最初导致所有源文件重新编译的情况。请注意,在演示中,无论哪种方式编译都不会花费很长时间,但可以想象一下在大项目上的速度提升。

模板解决方案的缺点

  1. 告别编译时的咖啡休息时间!
  2. 有 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 的)没有一种是错误的,它们都有不同的缺点。你必须为每种情况选择合适的技术。也没有必要将项目中的每个类都更改为这些技术之一,但对文件结构中的关键点进行一些更改可以大大缩短编译时间。

© . All rights reserved.