《Effective C++》学习笔记——条款31

***************************************转载请注明出处:http://blog.csdn.net/lttree********************************************

五、Implementations

Rule 31:Minimize compilation dependencies between files

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

一、文件间的编译依存性

1.现象: 假设你对C++程序的某个class实现文件做了些轻微的修改。(而且,这里修改的并不是class接口,而是实现,而且只改private成分。

然后重新建置这个程序,你会发现所有的东西都需要重新编译和连接。

2.原因:问题出在C++并没有把"将接口从实现中分离"做的很好。

Class的定义式不只详细叙述了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;
};

这里的class Person无法通过编译——如果编译器没有取得其实现代码所用到的classes string,Date 和 Address的定义式。这样的定义式通常有#include指示符提供,所以Person类的定义文件最上方,会有一些头文件的包含,比如:

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

不幸的是,这样一来就在Person定义文件和其含入文件之间形成了一种编译依存关系。

这将会 导致 如果这些头文件中任何一个被改变,那么每一个含入Person class的文件都需要重新编译,任何使用Person class的文件也要重新编译。

3.疑问:为什么C++坚持将class的实现细目置于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的客户就只需要在Person接口被修改过时才重新编译。

4.导致的问题

这个想法存在两个问题:

(1) string不是个class,它是个typedef(定义为basic_string<char>)。因此上述针对string而做的前置声明并不正确(正确的前置声明因为涉及额外的templates所以比较复杂)

(2) 编译器必须在编译期间知道对象的大小,看看这个:

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

当编译器看到x的定义式,它知道必须分配多少内存(通常位于stack内)才够持有一个int。

每个编译器都知道一个int有多大,当编译器看到p的定义式,它也知道必须分配足够的空间以放置一个Person,但它如何知道一个Person对象有多大呢?

——编译器获得这项信息的唯一方法就是询问class定义式。

然而如果class定义式可以合法地不列出实现细目,编译器如何知道该分配多少空间?

二、编译器如何给相应对象分配空间

1.这个问题在 smalltalk、Java等语言上并不存在 —— 因为当我们用这些语言定义对象时,编译器只分配足够的空间给一个指针(用来指向对象)使用。

也就是说会是这样的:

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

对于C++来说,这肯定也算合法的,所以我们也可以试试 ——“将对象实现细目隐藏在一个指针背后”。

2.针对Person,我们可以将它分割为两个类,一个只提供接口,另一个负责实现接口:

#include <string>
#include <memory>

class PersonImpl;
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::address() const;
    ...
private:
   std::tr1::shared_ptr<PersonImpl> pImpl;
};

在这里,main class(Person)只内含一个指针成员(这里使用了 tr1::shared_ptr),指向其实现类(PersonImpl)。

这样的设计常被称为 pimpl idiom(就是 pointer to implementation)。

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

这个分离的关键在于——用“声明的依存性”替换“定义的依存性”。

这也正是
编译依存性 最小化的本质:现实中,让头文件尽可能的自我满足,万一做不到,则让它与其他文件内的声明式(非定义式)相依。

3.对此,有一个简单的设计策略:

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

你可以只靠一个类型声明式就定义出指向该类型的reference和pointer;但如果定义某类型的object,就需要用到该类型的定义式。

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

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

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

当然,pass-by-value 一般来说,并不是个好事(详见 条款20),但如果你发现因为某种因素被迫实用它,并不能够就此为“非必要之编译依存关系”导入正当性。

声明today函数和clearAppointments函数而无需定义Date,或许会让你不习惯,但是这也是有原因的。一旦任何一个人调用那些函数,调用之前 Date定义式 一定要先曝光,那么问题来了—— 何必费心声明一个没人调用的函数呢?

其实,并不是没人调用,而是并非每个人都调用。

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

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

根据上面的准则,我们需要两个头文件,一个用来声明,一个用来定义。而且这两个文件必须保持一致性,因此程序库客户应该总是#include一个生命稳健而非前置声明若干函数,程序库作者也应该提供这两个头文件。

举个例子:

Date的客户如果希望声明today和clearAppointments,他们不该像先前那样以手工方式前置声明Date,而是应该#include适当的、内含声明式的头文件

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

PS:根据C++标准程序库头文件的命名规则,将只含声明式的头文件命名为"datefwd.h",就像<iosfwd>一样。

<iosfwd> 内含 iostream各组件的声明式,其对应定义则分布在若干不同的头文件内,包括<sstream>、<streambuf>、<fstream>、<iostream>。

<iosfwd>还彰显了“本条款适用于template 也适用于 non-template”。虽然在条款30说过,在许多建置环境中template定义式通常被置于头文件内,但也有某些建置环境允许template定义式放在“非头文件”内,这么一来就可以将“只含声明式”的头文件提供给template。

三、关于 handle class 与 interface class

1.像Person这样使用 pimpl idiom 的class,往往被称为 handle classes。

然而,handle class如何工作呢?

① 它们的所有函数转交给相应的实现类并由后者完成实际的工作。

比如:

#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成为一种特殊的 abstract base class(抽象基类),称为Interface class。

这种class是用来 描述 derived class的接口,因此它通常不带成员变量,也没有构造函数,只有一个 virtual析构函数以及一组 pure virtual函数,用来叙述整个接口。

2.关于 Interface class

Interface class类似 Java 和 .NET 的Interface,但C++的Interface class并不需要负担Java 和 .NET 的 Interface 所需负担的责任。

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

一个针对 Person而写的 Interface class或许看起来像这样:

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

这个class的客户必须以 Person 的 pointers 和 reference 来撰写应用程序,因为它不可能针对“内含pure virtual函数”的Person class 具体出实体。就像Hnadle clas的客户一样,除非Interface class的接口被修改否则其客户不需要重新编译。

Interface class的客户必须有办法为这种class创建新对象。

方法1:通常调用一个特殊函数,此函数扮演“真正将被具现化”的那个derived class的构造函数角色。这样的函数通常称为factory函数或virtual构造函数。它们返回指针,指向动态分配所得对象,而对该对象支持Interface class的接口。这样的函数又往往在Interface class内被声明为static:

class Person  {
public:
    ...
    static std::tr1::shared_ptr<Person>     // 返回一个tr1::shared_ptr,指向一个新的Person,并以给定之参数初始化。条款18 告诉你,为什么返回tr1::shared_ptr
        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);

...
std::cout<<pp->name()            // 通过Person接口使用这个对象
              <<" was born on "
              <<pp->birthDate()
              <<" and now lives at "
              <<pp->address();
...                    // 当pp离开作用域,对象会被自动消除

支持Interface class接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。

一切都在virtual构造函数实现码所在的文件秘密发生。假设Interface class Person有个具象的 derive class RealPerson,后者提供继承而来的virtual函数的实现:

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

有了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实现代码会创建不同类型的 derive class对象,取决于诸如额外参数值、读自文件或数据库的数据、环境变量等。

RealPerson示范实现Interface class的两个最常见的机制之一:从Interface class(Person)继承接口规格,然后实现出接口所覆盖的函数。

四、总结

Handle class 和 Interface class 接触了接口和实现之间的耦合关系,从而降低了文件间的编译依存性。

? 在Handle class身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation
pointer的大小。最后,implementation pointer必须初始化,指向一个动态分配得来的implementation object,所以你将蒙受因动态内存分配而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性。

? Interface class,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外Interface class派生的对象必须内含一个vptr(virtual table pointer),这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface
class之外是否还有其他的virtual函数来源。

? 最后,不论Handle class 或 Interface class, 一旦脱离inline函数都无法有太大作为。但Handle class 和 Interface class 正是特别被设计用来隐藏实现细节如函数本体。

? 然而,如果只因为若干额外成本便不考虑Handle class 和 Interface class将是严重的错误。Virtual 函数不也带来成本吗?但我们并不想放弃它们。所以,我们应该考虑以渐进的方式使用这些技术。在程序发展过程中使用Handle class 和 Interface
class以求实现码有所变化时对其客户带来最小冲击。而当它们导致速度和/或大小差异过于重大以至于 class之间的耦合相形之下不成为关键时,就以具象类替换Handle class 和 Interface class。

请记住

☆ 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle class 和 Interface class

☆ 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及template 都适用

***************************************转载请注明出处:http://blog.csdn.net/lttree********************************************

时间: 2024-10-14 10:27:31

《Effective C++》学习笔记——条款31的相关文章

Effective C++学习笔记 条款07:为多态基类声明virtual析构函数

一.C++明确指出:当derived class对象经由一个base class指针被删除,而该base class带着一个non-virtual析构函数,其结果未定义——实际执行时通常发生的是对象的derived成分没有被销毁!(注:使用基类引用派生类的方式使用多态,由于引用只是对原对象的一个引用或者叫做别名,其并没有分配内存,对其引用对象内存的销毁,将由原对象自己负责,所以使用引用的时候,如果析构函数不为virtual,那么也不会发生derived成员没有销毁的情况) 例如: class b

effective c++学习笔记条款23-25

条款23:宁可用非成员,非友元函数来替代成员函数 1.非成员函数提供了更好的封装性,这个函数内不能访问类的私有成员,封装的越严密我们对类的数据就可以弹性越大的操纵,因为可见这些数据的客户越少,反之数据影响的客户也就越少. 2.c++比较自然的做法-(关系到标准库numplace的组织结构),可以把不同便捷函数放到不同Namespace去,让客户来决定要用的非成员函数功能,这是类不能提供的. 条款24:若所有参数皆需类型转换,请为此采用非成员函数. 1.如果你需要为某个函数的所有参数(包括被thi

effective c++学习笔记条款11-13

条款11: 1.令赋值运算符返回一个&,因为STL,string都是这样做的,除非你有足够好的理由不这样做. 2.处理自我赋值的方法----(1).在没有成功获取对象数据时不要删除自己的数据,避免发生异常后原对象指针是一个悬浮指针 (2).判断自我赋值的检查操作会耗费不少时间,可以用swap交换数据技术来优化---(1)形参为赋值而来,(2)形参为静态引用,多加一个函数内拷贝操作.

effective c++学习笔记条款8-10

条款7:为多态基类声明虚析构函数 1.一个基类指针接受一个派生类对象的地址时,对该指针delete,仅仅释放基类部分 2.给所有类都带上虚析构函数是个馊主意,会带有vptr指向一个函数指针数组,扩大不必要的对象大小,除非补偿vptr,否则没有移植性. 3.string类和STL不含有虚析构函数,然而一些用户 却将他们作为基类,运用   delete指向派生类的基类指针,导致错误[c++11添加了禁止派生性质],他们不适合当基类. 4,手头上没有合适的纯虚函数,但你确实需要一个抽象类,把析构函数声

effective c++学习笔记条款20-22

条款20:用引用传递代替值传递 1.尽量以引用传递来代替传值传递,前者比较高效,并且可以避免切割问题 2.以上规则不适用于内置类型,以及STL的迭代器,和函数对象 条款21:必须返回对象时,别妄想返回对象的引用 1.绝对不要返回指针和引用指向一个局部对象或者静态局部对象而有可能需要多个这样的对象,条款4已经为在单线程环境合理返回&指向一个局部静态提供了一份设计实例.(保护初始化顺序) 条款22:将成员变量声明为private 1.切记将成员变量声明为private.这可赋予客户访问数据的一致性,

effective c++学习笔记条款4-7

条款4:确定对象被使用前已经初始化 一. 变量在不同情况下可能会初始化,也可能不会初始化. 注意初始化和赋值的区别. 1.在类中内置类型不会发生隐式初始化,自定义有默认构造函数的能被默认初始化 所以在构造类时务必初始化内置类型,最好给自定义的对象显示初始化避免在函数体中赋值浪费资源. 2.内置类型在函数体内不会初始化,在函数体外自动初始化为0. 二. 1.const和引用类型必须初始化,不可能赋值 三 1.当类实在是有较多构造函数,并且总是要对一些成员数据重复初始化,可以考虑将那些“赋值和初始化

effective c++学习笔记条款17-19

条款17:以独立语句将New对象放置入智能指针. 1.以独立语句将newed对象放置入智能指针内,如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄露. void name(shared_ptr<管理对象类型>(new 管理对象类型),其它函数)),New被分配内存不一定马上放入管理对象,因为有其它函数干扰,这不是独立语句. 条款18:让接口容易被正确使用,不易被误用. 1.好的接口很容易被正确使用,不容易被误用.你应该在你的所有接口中努力达成这些性质. 2.“促进正确使用”的办法包括接

effective c++学习笔记条款35-37

#include<iostream> using namespace std; class A { public: void asd() { pri(); } private: /*virtual*/ void pri() { cout << "基类函数" << endl; } }; class B :public A { private: void pri() /*override*/ { cout << "派生类函数&quo

effective c++学习笔记条款29-31

条款29:为异常安全而努力是值得的[回顾] 1.异常安全函数即使发生异常也不会泄露资源或允许任何数据结构败坏,这样的函数分为3种可能的保证:基本型,强烈型,不抛异常型 2.“强烈保证”往往能通过copying and swap 来实现出来,但并非所有函数都可实现或者具备现实意义. 3.函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的“异常安全中”的最弱者.