参考《Effective C++》中条款31:将文件的编译依存关系降至最低
C++实现接口与实现分离可以有多种方式,比如利用虚函数,来实现运行时的动态绑定,本文采用另一种方法,即代理模式,同时解决了文件间编译依赖关系的问题。
第一个例子没有实现接口与实现分离,作为一个反例,文件的依赖关系如下:
Name.h:定义了类Name
Name.cc:定义了类Name中的方法GetName()
Person.h:定义了类Person,包含文件Name.h,Person类中有一个Name类的private成员
Person.cc:定义了类Person中的方法,即接口GetName(),方法内部调用Name类的方法GetName()
main.cc:Person类的客户代码,包含Person.h文件,使用Person类提供的接口 GetName()
Name.h
#ifndef NAME_H
#define NAME_H
class Name
{
public:
void GetName() const;
};
#endif
Name.cc
#include "Name.h"
#include <iostream>
using std::cout;
using std::endl;
void Name::GetName() const
{
cout << "bad xxl" << endl;
}
Person.h
#ifndef PERSON_H
#define PERSON_H
// 注意,这里包含了Name.h头文件,形成了编译依赖关系
// 因为在private中有一个Name类型的成员,所以编译器必须知道Name的定义
// 故需要包含Name.h头文件
#include "Name.h"
class Person
{
public:
void GetName() const;
private:
Name name;
};
#endif
Person.cc
#include "Person.h"
void Person::GetName() const
{
name.GetName();
}
main.cc
#include "Person.h"
int main()
{
Person xxl;
xxl.GetName();
return 0;
}
makefile
main: main.o Person.o Name.o
g++ -o main main.o Person.o Name.o
main.o: main.cc Person.h
Person.o: Person.cc Person.h Name.h
Name.o: Name.cc Name.h
clean:
rm -f *.o main
为了让用户能使用类Person,我们必须提供Person.h文件,即在main.cc中包含Person.h,这样类Person的私有成员也暴露给用户了(因为通过预处理器,展开之后,类的具体定义将出现在main.cc中,,这样便能看到private成员。而且,仅仅提供Person.h文件是不够的,因为Person.h文件include了Name.h文件,在这种情况下,我们还要提供Name.h文件。那样Person类的实现细节就全暴露给用户了。
另外,当我们对类Name做了修改(如添加或删除一些成员变量或方法)时,我们还要给用户更新Name.h文件,而这个文件是跟接口无关的。如果类Person里面有很多像name那样的对象的话,我们就要给用户提供N个像Name.h那样的头文件,而且其中任何一个类有改动,我们都要给用户更新头文件。还有一点就是用户在这种情况下必须进行重新编译!
上面是非常小的一个例子,重新编译的时间可以忽略不计。但是,如果类Person被用户大量使用的话,那么在一个大项目中,重新编译的时间就会非常长了。可以想像一下用户在自己程序不用改动的情况下要不停的更新头文件和编译时,他们心里会骂些什么。其实对用户来说,他们只关心类Person的接口GetName()方法。那我们怎么才能只暴露类Person的GetName()方法而不又产生上面所说的那些问题呢?
答案就是--接口与实现的分离。我可以让类Person定义接口,而把实现放在另外一个类里面。下面是具体的方法:
首先,添加一个实现类PersonImp来实现Person的所有功能。注意:类PersonImp有着跟类Person一样的公有成员函数,因为他们的接口要完全一致。
PersonImp.h
#ifndef PERSONIMP_H
#define PERSONIMP_H
// 不能只在PersonImp.cc中包含Name.h,本文件被Person.cc包含。
// 而在本文件的PersonImp类中用到了Name类的对象(不是指针或引用),所以用前向声明是不行的,编译器必须要知道类Name的定义
#include "Name.h"
// PersonImp和Person有完全相同的成员函数,两者接口完全相同
class PersonImp
{
public:
PersonImp();
void GetName() const;
private:
// 真正的数据在这里
// name因为不是指针,而是类类型
// 所以编译器要知道类Name的大小
// 只用前向声明是不行的,故要包含
// Name.h头文件,编译时解析
Name name;
};
#endif
PersonImp.cc
#include "PersonImp.h"
PersonImp::PersonImp(){}
void PersonImp::GetName() const
{
name.GetName();
}
还要修改类Person
Person.h
#ifndef PERSON_H
#define PERSON_H
// PersonImp是前向声明,可用于函数“声明”的返回值和参数
// 但是不能用于函数“定义”的返回值和参数,
// 所以只能在头文件中用,在Person类的实现文件中,就得包含
// 真正的PersonImp定义了,即要在Person.cc中包含头文件PersonImp.h
class PersonImp;
class Person
{
public:
Person();
void GetName() const;
~Person();// 由于有动态分配的内存,故要自定义析构函数
private:
// 具体实现的指针或引用,不能是PersonImp的对象,否则编译不通过
PersonImp *pImp;
};
#endif
Person.cc
#include "Person.h"
// 这里要包含PersonImp.h了,否则无法"看见“(调用)其成员函数
#include "PersonImp.h"
Person::Person()
: pImp(new PersonImp) {}
void Person::GetName() const
{
// 使用具体“实现”中的方法
pImp->GetName();
}
Person::~Person()
{
if (pImp)
delete pImp;
}
main.cc
#include "Person.h"
int main()
{
Person xxl;
xxl.GetName();
return 0;
}
Name.h和Name.cc保持原样,不改动。
Name.h
#ifndef NAME_H
#define NAME_H
class Name
{
public:
void GetName() const;
};
#endif
Name.cc
#include "Name.h"
#include <iostream>
using std::cout;
using std::endl;
void Name::GetName() const
{
cout << "good xxl" << endl;
}
Makefile:
objects = main.o Person.o PersonImp.o Name.o
main: $(objects)
g++ -o main $(objects)
main.o:main.cc Person.h
Person.o:Person.cc Person.h PersonImp.h
PersonImp.o:PersonImp.cc PersonImp.h Name.h
Name.o:Name.cc Name.h
clean:
rm -f *.o main
通过上面的方法就实现了类Person的接口与实现的分离。请注意两个文件中的注释。类Person里面声明的只是接口而已,而真正的实现细节被隐藏到了类PersonImp里面。为了能在类Person中使用类PersonImp而不include头文件PersonImp.h,就必须有前置声明class PersonImp,而且只能使用指向类PersonImp对象的指针,否则就不能通过编译。在发布库文件的时候,我们只需给用户提供一个头文件Person.h就行了,不会暴露类Person的任何实现细节。而且我们对类Name的任何改动,都不需要再给用户更新头文件(当然,库文件是要更新的,但是这种情况下用户也不用重新编译!)。这样做还有一个好处就是,可以在分析阶段由系统分析员或者高级程序员来先把类的接口定义好,甚至可以把接口代码写好(例如上面修改后的Person.h文件和Person.cc文件),而把类的具体实现交给其他程序员开发。
第一次make时,结果如下:
xxl@xxl-pc:~/CPPCode/接口与实现分离$ make
g++ -c -o main.o main.cc
g++ -c -o Person.o Person.cc
g++ -c -o PersonImp.o PersonImp.cc
g++ -c -o Name.o Name.cc
g++ -o main main.o Person.o PersonImp.o Name.o
如果改动Name.h文件,比如加一个空格,则所有依赖于Name.h的文件都要重新编译,重新make,结果如下:
g++ -c -o PersonImp.o PersonImp.cc
g++ -c -o Name.o Name.cc
g++ -o main main.o Person.o PersonImp.o Name.o
如上所示,Name.h的改变只影响到Name.o自己,以及包含了Name.h的PersonImp.o文件,所以需要重新编译Name.cc,PersonImp.cc以分别生成Name.o和PersonImp.o文件,然后重新链接main.o,Person.o,PersonImp.o,Name.o以生成可执行文件main。
但是如果将makefile文件中的第6行,即:
PersonImp.o:PersonImp.cc PersonImp.h Name.h
改为:
PersonImp.o:PersonImp.cc PersonImp.h
那么如果改动Name.h文件,比如加一个空格,则只有Name.cc文件需要重新编译,生成Name.o。
g++ -c -o Name.o Name.cc
g++ -o main main.o Person.o PersonImp.o Name.o
而PersonImp相关的文件都不需要重新编译,最后.o文件链接生成main。这样看来岂不是更好?
其实不然,这里只是加了一个空格,如果改变了Name中的接口名,或者干脆把类Name的名字改为Name2(Name.h和Name.cc中同时改),则会出现“链接错误”:
g++ -c -o Name.o Name.cc
g++ -o main main.o Person.o PersonImp.o Name.o
PersonImp.o: In function `PersonImp::GetName() const':
PersonImp.cc:(.text+0x13): undefined reference to `Name::GetName() const'
collect2: ld 返回 1
make: *** [main] 错误 1
当把makefile改回原样,即保留第6行中的Name.h
PersonImp.o:PersonImp.cc PersonImp.h Name.h
那么,这个时候,Name改为Name2时出现的错误是个编译错误。如下:
g++ -c -o PersonImp.o PersonImp.cc
In file included from PersonImp.cc:1:0:
PersonImp.h:21:3: 错误: ‘Name’不是一个类型名
PersonImp.cc: 在成员函数‘void PersonImp::GetName() const’中:
PersonImp.cc:7:2: 错误: ‘name’在此作用域中尚未声明
make: *** [PersonImp.o] 错误 1
由于在真正的项目中,编译错误总是好于链接错误(参见《Effective C++》条款6,P39),毕竟越早检查出错误越好,而且通常链接错误较难理解(可能是由于编译器的name mangling机制)。所以,最好将链接期的错误提前到编译期。
其实,这也是设计模式中的代理模式
Person类其实就是代理类(Proxy),PersonImp类就是具体实现类(RealSubject),两者必须具有完全相同的接口,这可以通过让两者从同一抽象类继承的方式来解决,该抽象类声明几个纯虚函数,Proxy类和RealSubject类必须实现这些函数。
参考链接:http://blog.youkuaiyun.com/starlee/article/details/610825