C/C++编程:将文件中的编译依存关系降到最低

1059 篇文章 285 订阅

原因

请将文件中的编译依存关系降到最低。如果你没有做到的话,可能你只修改了一个小数据,但是修改重新编译连接整个程序。

问题出在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 的定义式。这样的定义式通常由#include提供:

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

不幸的是,这样一来Person定义文件和其含入文件之间形成了一种编译依存关系。这些头文件中有任何一个改变,或者这些头文件依赖的其他文件由任何改变,那么每一个含入Person类的文件就得重新编译,任何使用Person类的文件也必须重新编译。

那,C++为什么捕将实现细目分开描述?

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接口被修改过时才重新编译了。因为这个想法存在两个问题

  • string不是个类,而是个typedef(定义为basic_string< char>)。因此上面针对string的前置声明不正确,而且你本来就不应该尝试手动声明一部分标准程序库,你应该仅仅使用适当的#include完成目的
  • C++前置声明时,编译器必须在编译期间知道对象的大小。举个例子:
int main(){
	int x;
	Person p(parmas);
}

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

此问题在java等语言不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说相当于:

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

实现

当然,C++也可以做到”将对象实现细目隐藏在一个指针背后“的游戏。针对Person:将Person分割为两个类,一个只提供接口,另一个负责实现该接口:

#include<string>  //标准库组件不应该被前置声明
#include<memory>

class PersonImpl;  //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::shared_ptr<PersonImpl> pImpl;  // 指向实现类
};
// 这种设计常被称为pimpl idiom

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

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

  • 如果使用object reference或者object pointer可以完成任务,就不要使用object。你可以只靠一个类型声明式就定义出指向该类型的reference和pointer;但如果定义某类型的object,就需要用到该类型的定义式
  • 如果能够,尽量以类声明式调换类定义式。 (这个地方我不认同,请看C/C++编程:前置声明:倾向于使用#include,而不是前置声明)
  • 为声明式和定义式提供不同的文件 (这个地方我不认同,建议将声明放进头文件,定义放在源文件)

像Person这样使用pimpl idiom的类,往往被称为Handle classes。那么这样的类应该怎么做点真正的事情呢?

  • 方法一:将它们的所有函数转交给相应的实现类并由后者完成实际工作。比如:
#include "Person.h"
#include "PersonImpl.h" // 注意PersonImpl和Person有着完全相同的成员函数,两者接口完全相同
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。这种类的作用是描述派生类的接口,因此它通常不带成员变量,没有构造函数,只有一个虚析构函数以及一组纯虚函数

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

interface class的客户必须有办法为这种类创建新对象。它们通常调用一个特殊函数,此函数扮演”真正将被具现化“的那个派生类的构造角色。这样的函数通常叫做factory(工厂)函数或者virtual构造函数。它们返回指针(最好是智能指针),指向动态分配所得对象,而该对象支持interface class接口。这样的函数又往往在interface class内为static:

class Person{
public:
	static std::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address & addr);

客户会这样使用它们:

std::string name;
Data dateOfBirth;
Address address;

// 创建一个对象,支持Person接口
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));

// 通过Person的接口使用这个对象
pp->name();

当然,支持interface class接口的那个具现类必须被定义处理,而且真正的构造函数必须被调用:

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 = 0;
    std::string address() const = 0;
private:
	std::string theName;
	Date theBirthDate;
	Address theAddress;
};
std::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address & addr){
	return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

RealPerson示例实现了interface class的两个最常见的机制之一:从interface class继承接口规格,然后实现接口所覆盖的函数(第二个机制的实现设计多重继承)

代价

上面两种实现有什么代价呢?

  • 在handler class上,成员函数必须通过implementation pointer取得对象数据,这会为每一次访问增加一层间接性,每一个对象消耗的内存数据必须增加implementation pointer的大小。最后,implementation pointer必须初始化(在handle class构造函数内),指向一个动态分配的implementation object,所以将承受动态内存分配(和释放)的开销
  • 置于interface class,由于每个函数但是virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外interface class派生的对象必须包含一个vptr,这个指针可能会增加存放对象所需的内存数量-----实际取决于这个对象除了interface class之外是否还有其他虚函数来源
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值