《Effective C++》学习笔记(条款31:将文件间的编译依存关系降到最低)

本文探讨了C++中文件间编译依赖的问题,介绍了如何通过使用文件间的接口与实现分离(如pimpl idiom和接口类)、句柄类和接口类来减少这种依赖。通过实例和原则,阐述了如何设计和应用这些技术以提高代码灵活性和维护性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

最近开始看《Effective C++》,为了方便以后回顾,特意做了笔记。若本人对书中的知识点理解有误的话,望请指正!!!

1 文件间的编译依存性

假设你对 C++ 程序的某个 class 的实现文件做了些轻微的修改。注意,这里修改的并不是 class 接口,而是实现,而且只改 private 成分,然后重新 build 这个程序,你会发现所有的东西都需要重新编译和连接。

这个问题的出现是因为C++并没有把"将接口从实现中分离"做的很好。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;
};

这里的 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 ,将实现细目分开叙述(即将成员变量的 class 前置声明)?

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 的接口被修改过时才需要重新编译。这样的想法存在两个问题:

  • string 不是个 class,他是个typedef(定义为 basic_string<char>)。因此上述对 string 做的前置声明是错误的。正确的声明比较复杂,涉及到额外的模板,所以我们不应该尝试手工声明一部分标准程序库。你只要使用 #include 把头文件包含进去就行了,而且标准头文件不太可能成为编译瓶颈。

  • 编译器必须在编译期间知道对象的大小,例如:

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

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

2 接口与实现分离

而在Java等语言中并不需要知道对象的大小,因为编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说它们将上述代码视同这样子:

int main()
{
    int x;
    Person* p;
    ...
}

对于C++来说,这肯定也算合法的,所以我们也可以试试 ——“将对象实现细目隐藏在一个指针背后”。我们可以把Person分割为两个类,一个只提供接口,另一个负责实现该接口(即条款30提到的piml),Person的定义如下:

#include <string>		//标准程序库组件不该被前置声明
#include <memory>		//智能指针的头文件
 
class PersonImpl;		//Person 实现类的前置声明,即该类的成员只有数据,没有函数
class Date;				//Person 接口用到的类的前置声明
class Address;			//Person 接口用到的类的前置声明
class Person  {
public:
    Person( const std::string& name,const Date& birthday,const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::address() const;
    ...
private:
   std::tr1::shared_ptr<PersonImpl> pImpl;	//智能指针,指向 Person 的实现类
};

在这里,main class(Person)只内含一个指针成员(这里使用了 tr1::shared_ptr),指向其实现类(PersonImpl)。这样的设计常被称为 pimpl idiom(pointer to implementation)。这种类内的指针名称往往就是 pImpl

在这样的设计下,Person 的客户就完全与Dates,Address 以及 Person的实现细目分离了。那些 class 的任何实现被修改都不需要Person 客户端重新编译。此外由于客户无法看到 Person 的实现细目,也就不可能写出什么“取决于那些细目”的代码,这才是真正的 接口与实现分离

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

  • 如果使用 object references 或 object pointers 可以完成任务,就不要使用 objects

    你可以只靠一个类型声明式就定义出指向该类型的引用和指针(如果某个类型只有前置声明,还没有定义,这时也可以定义指向该类型的引用或指针)。

    但如果定义某类型的 object,就需要用到该类型的定义式。(定义某类型对象,编译器需要知道该类型的大小)

  • 如果能够,尽量以 class 声明式替换 class 定义式

    当声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以 值传递方式传递该类型的参数(或返回值)也一样:

    class Date;        					// Date类的前置声明
    Date today();        				// 没问题,这里并不需要Date的定义
    void clearAppointments(Date d);     
    

    当然,自定义类型使用值传递是糟糕的(见条款20)。如果你必须得使用值传递,这并不能以编译依存关系为借口而正当化。

    声明 today()clearAppointments() 而无需定义 Date,或许会让你不习惯,但是这也是有原因的。一旦任何一个人调用那些函数,调用之前 Date 必须得被定义。那么问题来,何必费心声明一个没人调用的函数呢?其实,并非没人调用,而是并非每个人都调用。

    假设我们有一个函数库内含数百个函数声明,不太可能每个客户都使用到每个函数。但如果能够将“提供class定义式”(通过#include完成)的义务,从“函数声明所在”的头文件 转移到 “含有函数调用”的客户文件,便可将“并非真正必要的类型定义”与客户端之间的编译依存性去除掉。

  • 为声明式和定义式提供不同的头文件

    根据上面的准则,我们需要两个头文件,一个用来声明,一个用来定义。而且这两个文件必须保持一致性,因此程序库客户应该总是#include 一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。举个例子:Date 的客户如果希望声明today()clearAppointments() ,他们不该像先前那样以手工方式前置声明 Date,而是应该 #include 适当的、内含声明式的头文件:

    #include "datefwd.h"        // 这个头文件内声明(但未定义)class Date
    Date today();
    void clearAppointments(Date d);
    

3 句柄类(handle class)和接口类(interface class)

Person 这样使用 pimpl idiom 的类,往往被称为句柄类,那么如何设计句柄类呢?

一是将它们的所有函数转交给相应的实现类并由后者完成实际的工作。例如:

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

二是令 Person 成为一种特殊的抽象基类,又称为接口类

这种类是用来描述派生类的接口(见条款34),因此它通常不带成员变量,也没有构造函数,只有一个虚析构函数以及一组纯虚函数,用来叙述整个接口。

接口类和 Java 和 .NET 的接口很相似,但C++的接口类并不需要负担Java 和 .NET 的接口所需负担的责任。

举个例子:Java 和 .NET 都不允许在接口内实现成员变量或成员函数,但C++不禁止这两样东西。C++这种更为巨大的弹性有它的用途,因为就如条款36所言,在同一个继承体系内的所有类的 “non-virtual 函数的实现”都一样,所以将此等函数实现为接口类的一部分也是合理的。例如 Person 的接口类是这样的:

class Person  {
publicvirtual ~Person();
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0; 
    ...
};

这个类的用户必须以 Person 的指针和引用来编写应用程序,因为含有纯虚函数的类不能实例化。就像句柄类的用户一样,除非接口类的接口被修改,否则其用户不需要重新编译。接口类的用户必须有办法为这种类创建新对象:

方法1:通常调用一个特殊函数,此函数扮演“真正将被实例化”的那个派生类的构造函数角色。这样的函数通常称为工厂函数或 virtual 构造函数。它们返回指针(更好的是智能指针),指向动态分配所得对象,而对该对象支持接口类的接口。这样的函数又往往在接口类内被声明为static:

class Person  {
public:
    ...
    // 返回一个tr1::shared_ptr,指向一个新的Person,并以给定之参数初始化。条款18 告诉你,为什么返回tr1::shared_ptr
    static std::tr1::shared_ptr<Person> create(const std::string& name,const Date& birthday,const Address& addr);
    ...
};

用户会这样使用它们:

std::string name;
Date dateOfBirth;
Address address;
...
      // 创建一个对象,支持Person接口
std::tr1::shared_ptr<Person> pp(Person::create(name,datefBirth,address);//拷贝构造函数
...
      // 通过Person接口使用这个对象
std::cout<<pp->name()<<" was born on "<<pp->birthDate()<<" and now lives at "<<pp->address();
...  // 当pp离开作用域,对象会被自动消除

当然,支持 接口类 接口的那个具体类(concrete classes)必须被定义出来,而且真正的构造函数必须被调用。一切都在 virtual 构造函数实现代码所在的文件内秘密发生。假设接口类 Person 有个具体的派生类 RealPerson,后者重写继承而来的 虚函数 的实现:

//RealPerson.h 文件
class RealPerson : public Person  {
public:
    RealPerson(const std::string& name,const Date& birthday,const Address& addr) 
        : theName(name),theBirthDate(birthday),theAddress(addr) {}
    
    virtual ~RealPerson()  { }
    std::string name() const;		//函数的实现一般在 RealPerson.cpp 文件中
    std::string birthDate() const;
    std::string address() const;
private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

有了 RealPerson ,写出 Person::create() 就真的一点夜不稀奇了:

std::tr1::shared_ptr<Person> Person::create( const std::string& name,const Date& birthday, const Address& addr)
{
    return std::tr1::shared_ptr<Person>(new RealPerson(name,birthday,addr));
}

一个更现实的 Person::create()实现代码会创建不同类型的派生类对象,取决于诸如额外参数值、读自文件或数据库的数据、环境变量等。
RealPerson 示范实现接口类的两个最常见的机制之一:从接口类(Person)继承接口规格,然后实现出接口所覆盖的函数。

接口类的第二个实现方法涉及多重继承(见条款40),暂且不讨论。

4 总结

句柄类和接口类解除了接口和实现之间的耦合关系,从而降低了文件间的编译依存性。

  • 在句柄类身上,成员函数必须通过实现类指针取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加实现类指针的大小。最后,实现类指针必须初始化(在句柄类的构造函数中),指向一个动态分配得来的实现类对象,所以你将蒙受因动态内存分配而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性。
  • 接口类由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外接口类派生的对象必须内含一个虚表指针vptr(virtual table pointer),这个指针可能会增加存放对象(虚函数表)所需的内存数量——实际取决于这个对象除了接口类之外是否还有其他的虚函数来源。
  • 不论句柄类或接口类, 一旦脱离 inline 函数都无法有太大作为。但句柄类和接口类正是特别被设计用来隐藏实现细节如函数本体。
  • 如果只因为若干额外成本便不考虑句柄类和接口类将是严重的错误。虚函数不也带来成本吗?但我们并不想放弃它们。所以,我们应该考虑以渐进的方式使用这些技术。在程序发展过程中使用句柄类和接口类以求实现代码有所变化时对其用户带来最小冲击。而当它们导致速度和/或大小差异过于重大以至于类之间的耦合可以稍微忽视,就以具体类替换句柄类和接口类。

Note:

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

条款32:确定你的public继承塑模出is-a关系

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值