Effective C++ 读书笔记(十一)

条款三十一:将文件间的编译依存关系降至最低

假设你对c++的实现文件做了轻微修改(实现而非接口),而且只改了private部分,然后重新构建程序,发现所有部分都被重新编译和链接了!当这种事情发生,难道你不气恼吗?

问题出在c++并没有把将接口从实现中分离这事做得很好。类的定义式不仅详细叙述了类接口,还包括十足的实现细目

class Person{
public:
    Person(const string& name,const Date& birthday,const address & addr);
string name()const;
string birthDate()const;
string address()const;
private:
    string name;   //实现细目
    Date birthday;   //实现细目
    address  addr;//实现细目
};

这个类无法通过编译,因为Date,address等定义式没有被获取到。这样的定义式通常由include提供,所以Person定义文件很可能包含以下东西

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

这样一来Person定义文件和其汉儒文件之间形成了一种编译依存关系。如果这些头文件中任何一个被改变,或这些头文件所依赖的其他头文件有改变,那么每一个含Person类的文件就得重新编译,任何一个使用Person类的文件也必须重新编译。这样的连串编译会对许多项目造成难以形容的伤害。

你或许会奇怪,为什么c++坚持类的实现细目置于类的定义式中?为什么不这样定义Person?

namespace std{
class string;//前置声明,不正确
}
class Date;//前置声明
class address;//前置声明
class Person{
public:
    Person(const string& name,const Date& birthday,const address & addr);
string name()const;
string birthDate()const;
string address()const;
private:
    string name;   //实现细目
    Date birthday;   //实现细目
    address  addr;//实现细目
};

如果可以那么做,Person的客户就只需要在Person接口被修改过时才重新编译。

这个想法存在两个问题。第一,string不是类,它是typedef。因此上述对string的声明不正确,正确的比较复杂,但我们也不应该手工声明标准库。你应该仅使用适当的include解决问题,并且标准头文件也不太可能成为编译瓶颈。

第二个困难是,编译器必须在编译期间知道对象的大小。

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

当编译器看到x,它知道应该分配多少内存。但当编译器看到p时,它也必须分配足够空间放置Person,但它如何知道Person对象有多大呢?唯一办法就是询问类定义式,若类定义式可以合法的不列出实现条目,编译器如何知道该分配多少内存?

在其它语言上不存在,编译器只分配足够空间给一个指针,也就是他们将上述代码视为

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

这在c++中也是合法的。针对Person我们可以这样做:把Person分割为两个类,一个只提供接口,另一个负责实现该接口。

class PersonImpl;//Person的实现类前置声明
class Date;//前置声明
class address;//前置声明
class Person{
public:
    Person(const string& name,const Date& birthday,const address & addr);
string name()const;
string birthDate()const;
string address()const;
private:
   shared_ptr<PersonImpl>pImpl;
};

这里,Person类只含一个指针成员,指向其实现类PersonImpl。这般设计称为pimpl idiom。这种类内指针名称往往即使pImpl。

这样的设计之下,Person客户端就与Date,address以及Person的实现细目分离了。那些类的任何实现修改都不需要Person客户端重新编译。由于客户无法看到Person的实现细目,也就不可能写出基于其的代码,这真正是接口与实现分离。

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

  • 如果使用对象的引用或指针可以完成任务,就不要使用对象本身。可以只靠一个类型声明式就定义出指向该类型的引用和指针;但如果定义某类型的objects,就需要用到该类型的定义式。
  • 如果能的话,尽量以类声明式替换类定义式。注意,当你声明一个函数而用到某个类时,你并不需要该类的定义;纵使函数以值传递的方式传递该类型的参数亦然:

        

class Date;
Date today(); //这里不需要
void clearAppointments(Date d); //Date的定义式

一般而言,值传递是个糟糕的主意,但如果不得不使用,并不能够就此为“未必要之编译关系”提供正当性。 

声明today和clearAppointments函数不需要定义Date,这可能会令你惊讶,但它其实不是那么神奇。一旦任何人调用那些函数,调用之前Date定义式一定得先曝光才行。那么你或许会纳闷,何必费心声明一个没人调用的函数呢?,假设你有一个函数内包括数百个函数声明,不太可能每个客户叫一遍函数。如果能够将提供类定义式的义务从函数声明所在的头文件转移到内涵函数调用的客户文件中,便可将并非真正必要之类型定义与客户端之间的编译依存去掉。

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

为了严守上述规则,需要两个头文件,一个用于声明式、一个用于定义式。这些文件必须保持一致性,如声明式被改变了,两个文件都得改变。因此程序库客户应该总是include一个声明文件而非前置声明若干函数。举例,Date客户如果希望声明today和clearAppointments,他们不该以手工方式前置声明Date,而是应该#include适当的、内涵声明式的头文件:

#include "datefwd.h" //头文件内声明class Date
Date today();
void clearAppointments(Date d);

只含声明式的那个头文件名为“datefwd.h",命名方式参考标准库头文件<iosfwd>。

<iosfwd>有启发意义的另一个原因是,它分外彰显本条款适用于模板也适用于非模板。虽然条款30中说到模板定义式往往被置于头文件内,但也有些构建环境允许模板定义式放在非头文件内,这样一来就可以将只含定义式的头文件提供给模板,<iosfwd>就是这样一份头文件。

c++也提供export关键字,允许将模板声明式和定义式分割与不同的文件。

像Person这样使用pimpl idiom的类往往被称为handle class。这样的类如何做事呢?办法之一就是将他们所有的函数转交给相应的实现类由后者完成实际工作。如下

#include "Person.h"
#include "PersonImpl.h"
 Person::Person(const string& name,const Date& birthday,const address & addr)
        :pImpl(new PersonImpl(name,birthday,addr))
{}
string Person::name()const
{
    return pImpl->name();
}

这里Person构造函数以new调用PersonImpl构造函数,以及Person::name调用PersonImpl::name,让Person类变成一个Handle class并不会改变其做的事情,只会改变它做事的方法。

另一个制作Handle class的方法是,令Person成为一种特殊的abstract base class的,成为接口类。这种类的目的是详细一一描述派生类的接口,因此它通常不带成员变量,也没有构造函数,只有一个虚析构函数和一组纯虚函数,用来叙述整个接口。

接口类类似java的接口,但c++的接口不禁止在接口内实现成员变量和成员函数,这种更为巨大的弹性有其用途。如条款36所言,非虚函数的实现对继承体系中的所有类都应该相同,所以将此等函数实现为接口类的一部分也是合理的。

针对Person而写的接口类看起来像这样

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

};

这个类的客户必须以Person的指针和接口在写程序,因为它不可能针对内涵纯虚函数的类具体出实体。

接口类的客户必须有办法为这种类创建对象。他们通常调用一个特殊函数,此函数扮演真正将被具体化的那个派生类的构造函数角色。这样的函数通常称为工厂函数或虚构造函数,它们返回指针,指向动态分配所得对象,而该对象支持接口类的接口。这样的函数往往在接口类内被声明为为静态

class Person{
public:
    static shared_ptr<Person> create(string& name,const Date& birthday,const Address& addr);
};
//用户会这样使用
string name;
Date dateOfBirth;
Address address;
//创建一个对象,支持Person接口
shared_ptr<Person> pp(Person::create(name,dateOdBirth,address));

cout<<pp->name()<<pp->birthDate()<<pp->address();
//当pp离开作用域,对象会被自动删除

当然,支持接口类接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在虚构造函数实现代码所在文件秘密发生。假设接口类Person有个具象的派生类RealPerson,后者提供继承而来的虚函数实现

class RealPerson::public Person{
public:
RealPerson (const string& name,const Date& birthday,const address & addr)
        :thename(name),theBirthDay(birthday),theAddress(addr)
{}
virtual ~RealPerson(){};
string name()const;
 string birthDate()const;
 string address()const;
private:
    string thename;
    Date theBirthDate;
Address theAddress;
};

有了RealPerson之后,写出Person::create就不稀奇了:

static shared_ptr<Person> create(string& name,const Date& birthday,const Address& addr)
{
    return shared_ptr<Person>(new RealPerson(name,birthday,addr));
}

一个更显示的Person::create实现代码会创建不同类型的派生类对象,取决于其创建环境。

RealPerson实现接口类的两个最常见机制之一:从接口类继承接口规格,然后实现出接口所覆盖的函数。接口类第二个实现涉及到多重继承。

handle类和接口类解除了接口和实现之间的耦合关系,从而降低了文件之间的编译依存性。

在handle类身上,成员函数必须通过implementation pointer取得对象数据,那会为每一次访问问增加一层间接性。而每一个对象消耗的内存必须增加implementation pointer的大小。并且implementation pointer必须初始化指向一个动态分配而来的implementation 对象,这也会有内存分配的额外开销。

置于接口类,由于没和函数都是虚函数,所以你必须为每次函数调用付出一个间接跳跃成本。此外接口类派生的对象必须内涵一个vptr。

最后,无论是hanle类还是接口类,一旦脱离inline都为u发油太大作为。条款30解释为什么函数本体为了被inline必须置于头文件内,但handle类和接口类正是特别被设计用来隐藏实现细节如函数本体。

然而,若因为成本就不再使用handle类和接口类,这是严重错误。虚函数也有成本,我们也不可能摒弃它。应该考虑以渐进方式使用这些技术。舒勇handle类和接口类是为了在实现码有改变时对客户带来最小冲击,而当他们导致速度或大小差异过大以至于耦合不成为关键,就以具象类替换handle类和接口类。

总结:

  • 支持编译依存性最小化的一般构想是:相依与声明式而不是定义式。基于此构想的两个方法是handle类和接口类
  • 程序库头文件应该以完全仅有声明式的形式存在。这种做法无论是否设计模板都适用。

条款三十二:确定你的public继承塑膜出is-a关系

public继承意味着is-a的关系。

如果你令class D以public的形式继承class B,那么就是告诉编译器说每一个类型为D的对象也是一个类型为B的对象,反之不成立。意思是B比D表现出更一般化的特点,D比B表现出更特殊化的概念。c++对于public继承严格奉行上述见解。

class Person{...};
class Student:public Person{...};

每个学生都是人,但人不一定是学生,这便是这个继承关系的主张。

在c++领域,任何函数如果期望获得一个Person的实参,也都愿意接受一个Student对象:

void eat(Person& p);
void study(Student& s);
Person p;
Student s;
eat(p);  //p是人
eat(s);  //正确,s是学生,学生也是人
study(s); //s是学生
study(p); //错误,p不是个学生

这个论点只有public继承才成立。private继承的意义于此完全不同,条款39详述,protected继承,那是一种意义至今仍然令人迷惑的东西。

public继承与is-a的等价关系听起来颇为简单,但有时候直觉可能会误导你。比如,企鹅是一种鸟,这是事实。鸟可以飞,这也是事实。

class Bird{
public:
    virtual void fly();//鸟可以飞
};
class Penguin:public Bird{
};

这个继承体系说企鹅可以飞,而我们知道这不是真的,怎么回事?

我们要说一般的鸟可以飞,有数种鸟不会飞,我们更改继承体系,使其描述更好的真实性。

class Bird{

};
class FlyBird:public Bird{
public:
    virtual void fly();
}
class Penguin:public Bird{
};

这样更能反映我们真实的意思。

即便如此,我们可能并未完全处理好这些事,对于某些软件系统而言不需要区分会不会飞,可能其正在处理鸟喙和鸟翅,那么原来的双class继承体系就可以令人满足。这反映出一个事实,世界上不存在适用所有软件的完美设计。所谓最佳设计,取决于系统希望做什么事。

另一个思想派别处理上面鸟的问题,就是令企鹅重新定义fly函数,令它产生一个运行期错误:

void error(string msg);
class Penguin:public Bird{
public :
     virtual void fly(){error("attempt to make penguin fly");}
};

重要的是,这里所说的某些东西和想象不同,这里并不是说企鹅不会飞,而是企鹅会飞,但尝试那么做是一种错误。

如何描述其间的差异?企鹅不会飞可有编译器强制实施,但尝试飞行只有运行期才能检验出来。

为了表现企鹅本来就不会飞的限制,你可以不为企鹅定义fly函数

class Bird{
public:
        //没有声明fly
};
class Penguin:public Bird{
     //没有声明fly
};

现在如果尝试让企鹅飞,编译器就会加以谴责。

这和采取运行期发生错误完全不同,若以那种做法,编译器不会对p.flay()调用时发出任何抱怨。条款十八说:好的接口可以防止无效的代码通过编译。因此应该采用编译期拒绝企鹅飞行的设计。

回答一个简单问题:class Square应该以public形式继承class Rectangle吗?

你或许会回答当然应该,每个人都知道正方形是一种矩形,反之则不一定。

看下列代码:

class Rectangle{
public:
    virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int Height()const;
virtual int Width()const;
};
void makeBigger(Rectangle &r)//增加r的面积
{
    int oldHeight=r.Height();
r.setWith(r.Width()+10);
assert(r.height()==oldHeight);//判断r的高度是否未曾改变
}

显然,assert永远为真,因为makeBigger只改变r的宽度,r的高从未改变。

先考虑以下代码

class Square: public Rectangle{...};
Square s;
assert(s.width()==s.height());//判断正方形
makeBigger(s);
assert(s.width()==s.height()); //对所有正方形应该仍为真

我们分析上述assert语句

调用makeBigger之前,s的高度和宽度相同

makeBigger函数内,s的宽度改变,但高度不变

makeBigger返回后。s的高度与宽度再次相同

怎么样?

我们在其他领域的直觉,在这里无法预期般的受到帮助。本例的根本困难在于:可施行与矩形的事却不可施行与正方形身上。但public继承主张,能够实行在基类对象的每一件事也可以施行与派生类对象。在上例中这样的主张无法保持,所以用public继承塑膜他们之间的关系并不正确。编译器会通过,但代码通过编译并不代表就可以正确运作。

is-a并非是存在于类间的唯一关系,另外两个常见关系是has-a(有一个)和is-implemented-in-terms-of(根据某物实现出)。这些关系在条款38和39讨论。将上述任何一个关系误塑造为is-a在c++中并不罕见,所以我们需要确定确实了解这些类相互关系的差异。

总结:

  • public继承意味着is-a。适用于基类身上的每一件事一定也适用于派生类,因为派生类对象也都是一个基类对象。

条款三十三:避免遮掩继承而来的名称

这个题材其实和继承无关,而是和作用域有关。比如

int x;//global
void someFunc()
{
    double x; //local
    cin>>x;
}

这个读取数据的语句指的是local变量x,而不是global变量x,因为内层作用域的名称会掩盖外围作用域的名称。

当编译器处于someFunc的作用域内使用名称x时,它会现在local作用域内查找,如果找到就不再找其他作用域。本例中someFuc的x是double类型而global x是int类型,但那不要紧。c++的名称遮掩规则所做的唯一事情就是遮掩名称。至于名称类型则不重要。本例一个名为x的double遮掩了名为x的int。

现在引入继承。当派生类的成员函数内指涉基类的某物时,编译器可以找出我们所指涉的东西,因为派生类继承了声明于基类的所有东西。实际运作方式是,派生类作用域被嵌套在基类作用域内

class Base{
private:
    int x;
public:
    virtual void mf1()=0;
    virtual void mf2();
void mf3();
};
class Derived:public Base{
public:
    virtual void mf1();
    void mf4();
    ...
};

此例中混合了public和private名称,以及一组成员变量和成员函数名称。这些成员函数包括虚函数、纯虚函数、非虚函数,这是为了强调我们谈名称而非类型。此例中也可以加入其他类型。本例使用单一继承,多重继承以此类推。

假设派生类中mf4函数实现代码像这样

void Derived::mf4()
{
mf2();
}

当编译器遇到mf2,必须估算其指的什么。编译器的做法是查找个作用域,看看有没有某个mf2声明式。首先查找mf4覆盖的作用域,没找到又查找Derived作用域没找到再找Base作用域,在那编译器找到了名为mf2的函数,若还没找到查找动作会继续进行下去,首先是内涵Base的namespace作用域,再是全局作用域。

再考虑一个例子,这次我们重载mf1和mf3,并且添加一个新版mf3到Derived中。这里发生的事是派生类重载了mf3,那是一个非虚函数,不合理,但是我们西安不管。

class Base{
private:
    int x;
public:
    virtual void mf1()=0;
 virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);

};
class Derived:public Base{
public:
    virtual void mf1();
    void mf3();
    void mf4();
    ...
};

基类的所有名为mf3和mf1的函数都被派生类覆盖掉了。从名称查找观点来讲,mf1和mf3不再被继承。

Derived d;
int x;
d.mf1();//没问题,调用Derived::mf1
d.mf1(x);//错误,base的有参mf1被覆盖
d.mf2();//正确,调用继承来的
d.mf3();//正确,调用Derived
d.mf3(x);//错误,被遮掩

即使基类和派生类内的函数有不同的参数也适用,不论虚函数还是非虚函数也都适用。

这些行为最后的理由是为了防止你在程序库或应用框架内建立新的派生类时附带的从疏远的基类继承重载函数。实际上你使用public继承而又不继承重载函数,就是违反is-a关系,而条款三十二说过is-a是public继承的基石。

你可以使用using声明式达到目标:

class Base{
private:
    int x;
public:
    virtual void mf1()=0;
 virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);

};
class Derived:public Base{
public:
    using Base::mf1;//让Base类内名为mf1和mf3的所有东西在派生类作用域内都可见
    using Base::mf3;
    virtual void mf1();
    void mf3();
    void mf4();
    ...
};

现在,继承机制一如往昔的运作。

Derived d;
int x;
d.mf1();//没问题,调用Derived::mf1
d.mf1(x);//没问题,调用Base::mf1
d.mf2();//正确,调用继承来的
d.mf3();//正确,调用Derived
d.mf3(x);//没问题,调用Base::mf3

这意味着如果你继承基类并加上重载函数,而你又希望重新定义其中一部分,那么你必须为那些原本会被遮掩的名称引入using声明,否则该名称会被遮掩。

有时候你并不想继承base class 的所有函数,这是可以理解的。但在public继承下,这决不可能发生,因为其违反了is-a关系。然而其在private继承下它却可能是有意义的。若Derived以private形式继承Base,而Derived唯一想继承的mf1是那个无参版本。using声明式在这里排不上用场,因为其继承而来的所有同名函数在Derived class中都可见。这里需要不同的技术,即一个简单的转交函数

class Base{
private:
    int x;
public:
    virtual void mf1()=0;
 virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);

};
class Derived:private Base{
public:
  
    virtual void mf1()
{    Base::mf1();}//暗自成为inline
    void mf3();
    void mf4();
    ...
};
Derived d;
int x;
d.mf1();//没问题,调用Derived::mf1
d.mf1(x);//错误,被遮掩

inline转交函数的另一个用途是为那些不支持using声明式的老旧编译器另辟一条新路,将继承而得的名称汇入Derived class作用域内。

当结合template,我们将面对继承名称被遮掩的全新形式。详细在条款四十三。

总结:

  • derived class内的名称会遮掩base类内的名称。public继承下,没有人希望如此。
  • 为了让遮掩的名称重见天日,可使用using声明式或转交函数。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值