Effective C++条款31、32、33

条款三十一:将文件间的编译依存关系降至最低(第一部分)
在说这一条款之前,先要了解一下C/C++的编译知识,假设有三个类ComplexClass, SimpleClass1和SimpleClass2,采用头文件将类的声明与类的实现分开,这样共对应于6个文件,分别是ComplexClass.h,ComplexClass.cpp,SimpleClass1.h,SimpleClass1.cpp,SimpleClass2.h,SimpleClass2.cpp。

ComplexClass复合两个BaseClass,SimpleClass1与SimpleClass2之间是独立的,ComplexClass的.h是这样写的:

#ifndef COMPLESS_CLASS_H
#define COMPLESS_CLASS_H

#include “SimpleClass1.h”
#include “SimpleClass2.h”

class ComplexClass
{
    SimpleClass1 xx;
    SimpleClass2 xxx;
};
…

#endif /* COMPLESS _CLASS_H */



我们来考虑以下几种情况:

Case 1:
现在SimpleClass1.h发生了变化,比如添加了一个新的成员变量,那么没有疑问,SimpleClass1.cpp要重编,SimpleClass2因为与SimpleClass1是独立的,所以SimpleClass2是不需要重编的。
那么现在的问题是,ComplexClass需要重编吗?

答案是“是”,因为ComplexClass的头文件里面包含了SimpleClass1.h(使用了SimpleClass1作为成员对象的类),而且所有使用ComplexClass类的对象的文件,都需要重新编译!

如果把ComplexClass里面的#include “SimpleClass1.h”给去掉,当然就不会重编ComplexClass了,但问题是也不能通过编译了,因为ComplexClass里面声明了SimpleClass1的对象xx。那如果把#include “SimpleClass1.h”换成类的声明class SimpleClass1,会怎么样呢?能通过编译吗?

答案是“否”,因为编译器需要知道ComplexClass成员变量SimpleClass1对象的大小,而这些信息仅由class SimpleClass1是不够的,但如果SimpleClass1作为一个函数的形参,或者是函数返回值,用class SimpleClass1声明就够了。如:

// ComplexClass.h
class SimpleClass1;
…
SimpleClass1 GetSimpleClass1() const;
…


但如果换成指针呢?像这样:

// ComplexClass.h
#include “SimpleClass2.h”

class SimpleClass1;

class ComplexClass: 
{
    SimpleClass1* xx;
    SimpleClass2 xxx;
};



这样能通过编译吗?

答案是“是”,因为编译器视所有指针为一个字长(在32位机器上是4字节),因此class SimpleClass1的声明是够用了。但如果要想使用SimpleClass1的方法,还是要包含SimpleClass1.h,但那是ComplexClass.cpp做的,因为ComplexClass.h只负责类变量和方法的声明。

那么还有一个问题,如果使用SimpleClass1*代替SimpleClass1后,SimpleClass1.h变了,ComplexClass需要重编吗?
先看Case2。

Case 2:

回到最初的假定上(成员变量不是指针),现在SimpleClass1.cpp发生了变化,比如改变了一个成员函数的实现逻辑(换了一种排序算法等),但SimpleClass1.h没有变,那么SimpleClass1一定会重编,SimpleClass2因为独立性不需要重编,那么现在的问题是,ComplexClass需要重编吗?

答案是“否”,因为编译器重编的条件是发现一个变量的类型或者大小跟之前的不一样了,但现在SimpleClass1的接口并没有任务变化,只是改变了实现的细节,所以编译器不会重编。

Case 3:

结合Case1和Case2,现在我们来看看下面的做法:

// ComplexClass.h
#include “SimpleClass2.h”

class SimpleClass1;class ComplexClass
{
    SimpleClass1* xx;
    SimpleClass2 xxx;
};


 

// ComplexClass.cpp

void ComplexClass::Fun()
{
    SimpleClass1->FunMethod();
}



请问上面的ComplexClass.cpp能通过编译吗?

答案是“否”,因为这里用到了SimpleClass1的具体的方法,所以需要包含SimpleClass1的头文件,但这个包含的行为已经从ComplexClass里面拿掉了(换成了class SimpleClass1),所以不能通过编译。

如果解决这个问题呢?其实很简单,只要在ComplexClass.cpp里面加上#include “SimpleClass1.h”就可以了。换言之,我们其实做的就是将ComplexClass.h的#include “SimpleClass1.h”移至了ComplexClass1.cpp里面,而在原位置放置class SimpleClass1。
这样做是为了什么?假设这时候SimpleClass1.h发生了变化,会有怎样的结果呢?

SimpleClass1自身一定会重编,SimpleClass2当然还是不用重编的,ComplexClass.cpp因为包含了SimpleClass1.h,所以需要重编,但换来的好处就是所有用到ComplexClass的其他地方,它们所在的文件不用重编了!因为ComplexClass的头文件没有变化,接口没有改变!

总结一下,对于C++类而言,如果它的头文件变了,那么所有这个类的对象所在的文件都要重编,但如果它的实现文件(cpp文件)变了,而头文件没有变(对外的接口不变),那么所有这个类的对象所在的文件都不会因之而重编。

因此,避免大量依赖性编译的解决方案就是:在头文件中用class声明外来类,用指针或引用代替变量的声明;在cpp文件中包含外来类的头文件。

条款三十一:将文件间的编译依存关系降至最低(第二部分)
下面再来看书,去理解书上说的Handler classes就简单多了
假设我们要写一个Person类,如下:

class Person
{
private:
    string name;
    MyDate birthday;
    MyAddress address;

public:
    // fallows functions
    // ...
};


这个Person类里面包含有人的名字,人的生日以及地址,还有一些没有列出来的方法。注意到这里用到了string(它不是一个class,只是一个typedef,大多数情况下,我们认为它不会有任何改变),以及自定义的类MyDate与MyAddress,一种常见的做法是采用include头文件,像这样:

 #include <string>
 #include "MyDate.h"
 #include "MyAddress.h"

在MyDate.h里面写好日期类相关的成员变量与方法,而在MyAddress.h里面写好地址类相关的成员变量与方法。但如果此后要往MyDate类或者MyAddresss类添加成员变量,那么不仅仅所有用到MyDate或者MyAddress对象的文件需要重新编译,而且所有用到Person对象的文件也需要重编译,一个小改动竟然会牵涉到这么多的地方!

可以把问题归结为“C++并没有把将接口从实现中分离这件事做好”,因为包含头文件的做法很直观很方便,用的人也很普遍,而C++并没有对这种做法加以限制。

如果要把编译的依赖性降低,就要换一种思路来处理,不能出现定义式,只能出现声明式,代价是增加代码的复杂度以及性能上的一些损失。
书上提到了两种方法,第一种是采用Handler Classes(用指针指向真正实现的方法),第二种是Interface Classes(抽象基类)。
对于第一种Handler Class,一句话,就是.h里面不包含类的自定义头文件,用“class 类名”的声明方式进行代替(也要把相应的成员变量替换成指针或引用的形式),在.cpp文件里面包含类的自定义头文件去实现具体的方法。改造之后的程序看起来是这样子的:

// Person.h
#include <string>
using namespace std;

class PersonImp;

class Person
{
private:
    //string Name;
    //MyDate Birthday;
    //MyAddress Address;
    PersonImp* MemberImp;

public:
    string GetName() const;
    string GetBirthday() const;
    string GetAddress() const;
    // follows functions
    // ...
};
// Person.cpp
#include "PersonImp.h"
#include "Person.h"
string Person::GetName() const
{
    return MemberImp->GetName();
}
string Person::GetBirthday() const
{
    return MemberImp->GetName();
}
string Person::GetAddress() const
{
    return MemberImp->GetAddress();
}
// PersonImp.h
#ifndef PersonImp_H
#define PersonImp_H

#include <string>
#include "MyAddress.h"
#include "MyDate.h"
using namespace std;

class PersonImp
{
private:
    string Name;
    MyAddress Address;
    MyDate Birthday;

public:
    string GetName() const
    {
        return Name;
    }

    string GetAddress() const
    {
        return Address.ToString();
    }

    string GetBirthday() const
    {
        return Birthday.ToString();
    }
};#endif /* PersonImp_H */


 

// MyDate.h
#ifndef MY_DATE_H
#define MY_DATE_H

#include <string>
using namespace std;

class MyDate
{
private:
    int Year;
    int Month;
    int DayOfMonth;public:
    string ToString() const;
}
#endif /* MY_DATE_H */
// MyAddress.h
#ifndef MY_ADDRESS_H
#define MY_ADDRESS_H

#include <string>
using namespace std;

class MyAddress
{
private:
    string Country;
    string Province;
    string City;
    string Street;

public:
    string ToString() const;
};

#endif /* MY_ADDRESS_H */


这里有一点要说一下,在Person.h里面并没有使用MyDate*和MyAddress*,而是用了PersonImp*,由PersonImp里面包含MyDate与MyAddress,这样做的好处就是方便统一化管理,它要求PersonImp里面的方法与Person的方法是一致的。以后Person添加成员变量,可以直接在PersonImp中进行添加了,从而起到了隔离和隐藏的作用,因为客户端代码大量使用的将是Person,而不必关心PersonImp,用于幕后实现的PersonImp只面向于软件开发者而不是使用者。

书上是用shared_ptr来管理PersonImp的,使资源管理上更加科学与合理。

另外,书上也提倡把class x; class xx; class xxx;的声明放至一个名为”xxxfwd.h”的头文件里,比如”datefwd.h”,这个头文件里面只有声明式,而没有具体的类细节。也就是说,对于某个类,比如MyDate,应该分出三个文件,一个是datefwd.h,里面是一些用到的外来的class声明式;一个是MyDate.h里面是MyDate类的结构声明;一个是MyDate.cpp,它与MyDate.h配对,给出具体的实现细节。

条款三十一:将文件间的编译依存关系降至最低(第三部分)
下面来谈谈书中的第二部分,用Interface Classes来降低编译的依赖。从上面也可以看出,避免重编的诀窍就是保持头文件(接口)不变化,而保持接口不变化的诀窍就是不在里面声明编译器需要知道大小的变量,Handler Classes的处理就是把变量换成变量的地址(指针),头文件只有class xxx的声明,而在cpp里面才包含xxx的头文件。Interface Classes则是利用继承关系和多态的特性,在父类里面只包含成员方法(成员函数),而没有成员变量,像这样:

// Person.h
#include <string>
using namespace std;

class MyAddress;
class MyDate;
class RealPerson;

class Person
{
public:
    virtual string GetName() const = 0;
    virtual string GetBirthday() const = 0;
    virtual string GetAddress() const = 0;
    virtual ~Person(){}
};



而这些方法的实现放在其子类中,像这样:

// RealPerson.h
#include "Person.h"
#include "MyAddress.h"
#include "MyDate.h"

class RealPerson: public Person
{
private:
    string Name;
    MyAddress Address;
    MyDate Birthday;
public:
    RealPerson(string name, const MyAddress& addr, const MyDate& date):Name(name), Address(addr), Birthday(date){}
    virtual string GetName() const;
    virtual string GetAddress() const;
    virtual string GetBirthday() const;
};



在RealPerson.cpp里面去实现GetName()等方法。从这里我们可以看到,只有子类里面才有成员变量,也就是说,如果Address的头文件变化了,那么子类一定会重编,所有用到子类头文件的文件也要重编,所以为了防止重编,应该尽量少用子类的对象。利用多态特性,我们可以使用父类的指针,像这样Person* p = new RealPerson(xxx),然后p->GetName()实际上是调用了子类的GetName()方法。

但这样还有一个问题,就是new RealPerson()这句话一写,就需要RealPerson的构造函数,那么RealPerson的头文件就要暴露了,这样可不行。还是只能用Person的方法,所以我们在Person.h里面加上这个方法:

 // Person.h
 static Person* CreatePerson(string name, const MyAddress& addr, const MyDate& date);


注意这个方法是静态的(没有虚特性),它被父类和所有子类共有,可以在子类中去实现它://

RealPerson.cpp
#include “Person.h”
Person* Person::CreatePerson(string name, const MyAddress& addr, const MyDate& date)
{
    return new RealPerson(name, addr, date);
}
1 // Main.h
2 class MyAddress;
3 class MyDate;
4 void ProcessPerson(const string& name, const MyAddress& addr, const MyDate& date);
// Main.cpp
#include "Person.h"
#include “MyAddress.h”;
#include “MyDate.h”;

void ProcessPerson(const string& name, const MyAddress& addr, const MyDate& date)
{
    Person* p = Person::CreatePerson(name, addr, date);
…
}



就可以减少编译依赖了。

总结一下,Handler classes与Interface classes解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。减少编译依存性的关键在于保持.h文件不变化,具体地说,是保持被大量使用的类的.h文件不变化,这里谈到了两个方法:Handler classes与Interface classes。

Handler classes化类的成员变量为指针,在.h文件里面只包含class xxx的外来类声明,而不包含其头文件,在.cpp涉及到具体外来类的使用时,才包含xxx.h的头文件,这样最多只影响本身类的cpp重编,但因为.h文件没有变化,所以此类的对象存在的文件不必重编。

当然,书上说的Handler classes更想让我们在类A的基础上另造一个中间类AImp(成员函数完全与类A一致),这个中间类的成员中里面放置了所有类A需要的外来类的对象,然后类的逻辑细节完全在Almp.cpp中实现,而在A.cpp里面只是去调用Almp.cpp的同名方法。A.h的成员变量只有Almp的指针,这看上去好像一个Handler,因此而得名。

Interface classes则是将细节放在子类中,父类只是包含虚方法和一个静态的Create函数声明,子类将虚方法实现,并实现Create接口。利用多态特性,在客户端只需要使用到Person的引用或者指针,就可以访问到子类的方法。由于父类的头文件里面不包含任何成员变量,所以不会导致重编(其实由于父类是虚基类,不能构造其对象,所以也不用担心由于父类头文件变化导致的重编问题)。

请记住:

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值