Effective C++读书笔记(20)

 条款31:将文件间的编译依存关系降至最低

Minimize compilation dependencies betweenfiles

假设你对一个类的实现进行了细微的改变。提醒你一下,不是类的接口,只是实现,仅仅是 private 的东西。然后你重建(rebuild)这个程序,在 Build上点击或者键入 make(或者其它等价行为),接着你突然意识到整个程序都被重新编译和连接!问题在于 C++ 没有做好从实现中剥离接口的工作。一个类定义不仅指定了一个类的接口而且有相当数量的实现细节。例如:

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 的实现使用到的类,也就是 string,Date 和 Address 的定义,类Person 就无法编译。这样的定义一般通过#include指令提供,所以在定义 Person 类的文件中,你很可能会找到类似这样的东西:

#include<string>
#include "date.h"
#include "address.h"

不幸的是,这样就建立了定义Person的文件和这些头文件之间的编译依赖关系。如果这些头文件中的一些发生了变化,或者这些头文件所依赖的文件发生了变化,每一个包含 Person 类的文件和使用了Person的文件一样必须重新编译,这样的连串编译依赖关系为项目带来难以形容的灾难。

编译器必须在编译期间知道它的对象的大小。考虑:

int main()
{
   int x; // define an int
   Person p( params ); // define a Person
   ...
}

当编译器看到 x 的定义,它们知道它们必须为保存一个int分配足够的空间(一般在栈上)。这没什么问题,每一个编译器都知道一个int有多大。当编译器看到p的定义,知道必须为一个Person分配足够的空间,它们参考这个类的定义来推测出一个Person对象有多大,但是如果一个省略了实现细节的类定义是合法的,编译器就不知道要分配多大的空间。

这个问题在Smalltalk和Java这样的语言中就不会发生,因为在这些语言中,当一个类被定义,编译器仅仅为一个指向对象的指针分配足够的空间。也就是说,它们将上述代码视同这样子:

int main()
{
int x; // define an int
Person *p; // define a pointer to a Person
...
}

当然,这是合法的 C++,所以我们也可以尝试“将类的实现隐藏在指针后面”。针对Person我们可以这样做,将它分开到两个类中,一个只提供一个接口,另一个实现这个接口。负责实现类名为 PersonImpl,Person可以如此定义:

    #include<string>
#include <memory> // tr1::shared_ptr

    classPersonImpl; // Person实现类的前置声明
class Date; // Person接口用到的类的前置声明
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;
...

    private:
    std::tr1::shared_ptr<PersonImpl>pImpl; // 指针,指向实现物
};

这样,主类(Person)除了一个指向它实现类的指针(PersonImpl,这里是一个 tr1::shared_ptr)之外不包含其它数据成员。这样一个设计经常被说成是使用了 pimpl手法。用这样的设计,使Person客户脱离 dates,addresses 和persons的细节。这些类的实现可以随心所欲地改变,但Person客户却不必重新编译。另外,因为他们看不到 Person 的实现细节,客户就不太可能写出依赖那些细节的代码。这就是接口和实现的真正分离。

这个分离的关键就是用对声明的依赖替代对定义的依赖。这正是最小化编译依赖的精髓:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依:

·    当对象的引用或指针可以做到时,就避免使用对象。仅需一个类型声明,你就可以定义出这个类型的引用或指针。而定义一个类型对象必须要存在这个类型的定义。

·    只要你能做到,就用类声明替代类定义。注意你声明一个使用某个类函数时,并不需要有这个类的定义,即使这个函数通过传值方式传递或返回这个类:

    classDate; // class声明式
Date today(); // fine
void clearAppointments(Date d); // Date的定义式

没有定义Date就可以声明today和clearAppointments,如果有人调用这些函数,则Date 的定义必须在调用之前被看到。但并非每个人都要调用它们,如果有一个包含很多函数声明的库,每一个客户都要调用每一个函数是不太可能的。通过将提供类定义的责任从声明函数的头文件转移到客户的包含函数调用的文件,就消除了客户对他们并不真正需要类型定义的依赖。

·    为声明和定义分别提供头文件。为了便于坚持上面的指导方针,头文件需要成对出现:一个用于声明,另一个用于定义。当然,这些文件必须保持一致,如果声明在一个地方被改变了,它必须在两处都被改变。因此客户应该总是#include一个声明文件,,而库的作者应该提供两个头文件。例如,想要声明today和clearAppointments 的 Date的客户不应该像前面展示的那样,手动前置声明Date,它应该#include适当的头文件:

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

只含声明式的头文件名"datefwd.h",命名方式取法C++标准程序库的头文件<iosfwd>。<iosfwd> 包含 iostream 组件的声明,其对应定义在几个不同的头文件中,包括 <sstream>,<streambuf>,<fstream> 和 <iostream>。

C++ 还提供了export关键字允许将模板声明从模板定义中分离出来。不幸的是,支持 export 的编译器非常少。

 

像Person这样的使用pimpl手法的类经常被称为Handle 类。如何让这样的类做一些实事,一种方法是将所有对他们的函数调用都转送给相应的实现类,由实现类来做真正的工作。(中转接口的感觉)例如,这就是两个Person成员函数的实现:

    #include"Person.h"
#include "PersonImpl.h" // 实现类里两个都要

Person::Person(const std::string& name,const Date& birthday,
const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}

std::string Person::name() const
{return pImpl->name();}

Person 的成员函数是调用 PersonImpl构造函数的(通过new),Person::name 是调用 PersonImpl::name 的。使Person成为一个Handle类不需要改变Person要做的事情,仅仅是改变了它做事的方法。

另一种方法是使Person成为一种特殊的抽象基类,称为Interface类。这样一个类的作用是为派生类指定一个接口,它的典型特征是没有数据成员和构造函数,只有一个virtual析构函数和一组指定接口的纯虚函数。

Interface 类类似 Java 和 .NET 中的Interface,但是C++并不会为Interface类强加那些 Java 和 .NET 为Interface设定的种种约束。例如,Java 和 .NET 不允许 Interface 中有数据成员和函数实现,但C++不禁止这些事情。Person的Interface类可能就像这样:

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

这个类的客户必须针对Person的指针或引用编程,因为实例化包含纯虚函数的类是不可能的(实例化从Person派生的类是可能的)。和Handle类的客户一样,除非Interface类的接口发生变化,否则Interface类的客户不需要重新编译。

Interface类的客户必须有办法创建新的对象。他们一般通过调用一个特殊函数,此函数扮演 “真正将被实例化”的那个派生类的构造函数角色。这样的函数一般称为factory函数或virtual构造函数。他们返回指向“动态分配且支持Interface类接口”的对象的指针(智能指针更好)。这样的函数在Interface类内部一般声明为static。假设Interface 类Person有一个具象的派生类 RealPerson:

class Person {
public:
    ...
    static std::tr1::shared_ptr<Person> create(const std::string& name,
    const Date& birthday, constAddress& addr);
    //返回std::tr1::shared_ptr,指向一个新的Person,并以给定参数初始化
    ...
};

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; // 省略这些函数的实现细节
    std::string birthDate() const;
    std::string address() const;

    private:
    std::string theName;
    Date theBirthDate;
   Address theAddress;
};

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));}

客户会这样使用它们:

    std::stringname;
Date dateOfBirth;
Address address;

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

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

Handle类和Interface类从实现中分离出接口,因此减少了文件之间的编译依赖。所有这些动作会消耗“一些运行时的速度和每个对象的一些额外内存”的代价。

Handle类:成员函数必须通过implementation指针得到对象的数据(增加一层间接性),而且必须在存储每一个对象所需的内存量中增加这一implementation指针的大小,这一指针必须被初始化(Handle类构造函数中)为指向一个动态分配的implementation对象,所以你要承受动态内存分配(及释放)的成本和遭遇bad_alloc(out-of-memory) 异常的可能性。

Interface类:每一个函数调用都是virtual(增加一层间接性),从Interface派生的对象必须包含一个vptr(virtualtable pointer),这个指针可能增加存储一个对象所需的内存量,取决于这个Interface类是否是这个对象虚函数的唯一来源。

最后,无论Handle类还是Interface类,一旦脱离inline函数都无法有太大作为。

在开发过程中,使用Handle类和Interface类来最小化实现发生变化时对客户的影响。当Handle类和Interface类导致的速度或大小差异过于重大,以至于类之间的耦合相形之下并不关键时,可以用具体类取代。

·    最小化编译依赖的一般想法是用对声明的依赖取代对定义的依赖。基于此想法的两个方法是 Handle类和 Interface类。

·   程序库头文件应该以完整并且只有声明式的形式存在。这种做法无论是否涉及template都适用。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值