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

假设修改calss的实现文件,不是修改class接口,只是实现中的private成分。然后你会发现全部编译和重新连接了。这些问题是因为C++没有“将接口从实现中分离”做得足够好。class的定义不只是描述了class接口,还包括实现细节。如下代码:

class Person
{
         public:
                   Person(conststring& name,const Date& birth,const Addr& addr);
                   std::stringname() const;
                   std::stringbirth() const;
                   std::stringaddr() const;
         ...
                   private:
                   std::string theName;  //实现细目
                   Date theBirth;       //实现细目
                   Addr theAddr;        //实现细目
};


以上代码无法编译如果没有取得class string ,Date,Addr的定义式。这样的定义式通常由#include提供,所以Person类的定义上面很可能有这些东西:
#include <string>
#include ”Date.h”
#include ”Addr.h”

	不幸的是这样使Person定义文件和其含入文件间形成了编译依存关系。如果这些头文件有改变,则含有Person类的文件都必须重新编译,连串编译依存关系会对很多项目造成灾难。
为什么C++坚持将class的实现细则定义于class定义式中?为何不将实现细目分开,如下代码:
namespace std  {  class  string;}   //前置声明,但不正确 下述
class  Date;
class  Addr;
class Person
{
         public:
                   Person(conststring& name,const Date& birth,const Addr& addr);
                   std::stringname() const;
                   std::stringbirth() const;
                   std::stringaddr() const;
         ...
};


这么做,Person只在修改接口时才重新编译,但存在两个问题、
1、string不是class,只是typedef,定义为basic_string<char>)。所以上面针对string的前置声明并不正确。正确的声明比较 复杂并涉及额外的templates。不应该手工声明标准程序库,应仅仅使用适当的#include完成目的。
2、“前置声明”比较重要也比较困难 是,必须在编译期间知道对象大小。如下:
int main()
{
int x;   //定义一个int
Person p(params);  //定义一个Person
…
}


编译看到int时能知道分配空间的大小,但Person对象有多大?获得大小的唯一办法是询问Class的定义,如果定义式不合法的列出实现细目则编译器不知道分配空间大小。
此问题在java等语言上并不存在。因为定义对象时,编译器只分配一个足够空间的指针,如下:
int main()
{
int x;
Person* p;  //指向Person对象的指针
…
}


这也是合法的C++代码,针对Person我们也可以这么做,将其分为两个类,一个提供接口,另一个负责实现该接口。取名为PersonImpl。即implementation  class,定义如下:
#include<string> //标准程序库文件不该被前置声明
#include <memory>  //为引用tr1::shared_ptr
class PersonImpl;
class Date; //Person实现类的前置声明
class Addr;   //…Person接口用到类的前置声明
class Person
{
         public:
                   Person(const string& name,const Date& birth,constAddr& addr);
                   std::stringname() const;
                   std::stringbirth() const;
                   std::stringaddr() const;
         ...
private:
std::tr1::shared_ptr<PersonImpl> pImpl;  //指向实现物
};


这样,main class(person)只含一个指针成员指向实现类,这种设计一般被称为pimpl idom,(pointer toimplementation缩写)。这种class类的指针名往往就是pImpl.
这样Person就完全与Date…等实现细目完全分离了,那些类的修改person都不需要重新编译。这些关键在于“声明的依存性”代替“定义的依存性”,这是编译依存最小化的本质。现实中让头文件尽可能自我满足,否则让它与其他文件内的声明式(而非定义式)相依。简单的设计策略:
1、如果用对象引用或指针能够完成任务,就尽量不用对象。可以只用类型声明式就能定义指针或引用,但如果定义对象就需用到该类的定义式。
2、尽量以class声明式替换class定义式。注意,如果声明某个函数用到class时,并不需要该类的定义式,即使是传值方式传递该类型凑数,如下:
class Date;   //类声明式
Date today();  //ok  尽管用不到
void clearAppontment(Date d); //Date定义式


虽然声明时不需用到Date定义,但调用时一定得提前曝光。虽然声明的是用不到的函数,但我们不能保证以后不会使用。如果将“cladd定义式”(通过#include完成)从“函数声明”头文件转移到“含函数调用”的客户文件,便可将“非真正的类型定义”和客户端间编依存性去掉。
3、为声明和定义提供不同的头文件。为了严守上述准则。客户应总是#include一个声明文件(而不是前置声明若干函数),程序库作者也应该提供这两个函数。如上,Date客户如果希望声明today和clearApp..,不应该手工前置声明,而应该#include适当的,内含声明式的头文件,如下:

#include “datefwd.h” //这个头文件声明但未定义Date
Date today();  
void clearAppontment(Date d);  
 
	datefwd.h命名方式取自C+标准程序库文件iosfwd,内含iostream各声明式其对应定义分布在若干不同头文件内。包括<sstream>,<streambuf>,<fstream>,<iostream>
	同样,虽然template定义通常位于头文件,但也有内置环境允许定义在非头文件中,这样可以把只含“声明式”的头文件提供给template.<iosfwd>就是这样的文件。

	虽然C+也提供关键字export允许template声明和定义位于不同文件,但支持这样的编译器很少

	这Person这样使用pimpl idom的class,往往称为handle class,将所有的函数交给相应实现类并由后者完成相应工作。下面是Person两个成员函数的实现:
#include “Person.h” //正在实现,必须#include其定义式
#include “PersonImpl.h” //同理,否则无法调用其成员函数。两者接口相同
Person::Person(const string& name,const Date&birth,const Addr& addr):
pImpl(new PersonImpl(name,birth,addr));
std::string Person::name() const
{
return pImpl->name();
}


Person以new调用(TK16)调用PersonImpl构造函数,name函数同理。Person变成一个handle class并不会改变它做的,只是改变做事方法。
另一个制作handle class办法是令Person变成一个Abstract clas(抽象基类),即Interface class.这种类的目的是详述类的接口、通常只有一个虚析构和一组纯虚函数。
一个针对Person而写的接口类应该像是这样的:
class Person
{
public:
virtual ~Person();
virtual std:;string name() const=0;
virtual std::string birthdate() const=0;
….
};


该类应以指针或引用来写程序,因为不可能针对内含纯虚函数的类具现实体。(当然派生类能具现出实体),这样除非接口改变,否则其他客户不需要重新编译。
interface class客户有办法为这种类创建新对象,调用一类特殊函数,和真正具体实例化的派生类构造函数角色一致。这样的函数通常为factory(工厂)函数或虚构造。他们返回指针(更为可取的是智能指针)指向动态分配对象。这样的函数在接口类中被声明为static:
class Person
{
…..
static std::tr1::shared_ptr<Person>  //返回智能指针指向新对象并用指定参数初始化
create(const std::string& name,const Date&birthday,const Addr& addr);
…..
}


客户这样使用:
std::string name;
Date dat;
Addr addr;
std::tr1::shared_ptr<Person>  pp(Person::create(name,dat,addr));
…
pp->name();pp->addr();  //通过Person接口使用对象 pp离开作用域自动删除Tk13


当然抽象类的实现都在虚构造实现文件类完成。假设虚基类有个派生类通过继承而来的虚函数实现:
class realPerson:public Person
{
public:
realPerson(const std::string&name,const Date& birthday,const Addr& addr):theName(name),
theBirth(birthday),theAddr(addr);
virtual ~ realPerson() {};
std::string name() const;
std::string birthday() const;
std::string address() const;
private:
std::string theName;
Date theBirth;
Addr theAddr;
}


有了realPerson之后,写出Person::create()就一点不奇怪了
std::tr1::shared_ptr<Person> Person::create(conststd::string& name,const Date& birthday,const Addr& addr)
{
return 
std::tr1::shared_ptr<Person>(new realPerson(name,birthday,addr));
}


上述示范是接口类的两个最常见机制之一,从接口类继承接口风格并实现接口覆盖的函数。接口类的第二个实现方法是多继承。
比较两者不同:
1、Handle class成员通过实现指针取得对象数据,增加间接性。每个对象内存消耗增加实现指针大小,另外有动态内存额外开销。
2、对于接口类,显然虚函数增多会增加虚函数表的大小。vptr(virtual tabal pointer)
但是二者脱离inline都 无法作为,TK30解释函数本体为了被inline必须(典型)

需要记住的:
	1、“编译依存最小化”的一般构想是:相依于声明式而不是定义式,基于此的两个手段是handle class和Interface class.
	2、程序库头文件应该以“完全且仅有声明式”的形式存在,此做法不论是否涉及template都适用。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值