Effective C++【四】(Scott Meyers著 侯捷译)

类与函数之实现(Classes and Functions:Implementation)

条款29:避免传回内部数据的handles
如果传回内部数据的handles,比如传回一个常量内部数据的地址,这样很容易被进行常量消除转换进而改变原常量内容,这是很不安全的。一般做法就是传回一份常量拷贝的地址,原来的常量handles仍然对外隐藏,这样虽然安全,但是使用者一定要注意删掉这个拷贝,不然将引发之前一直强调的memory leak,另外它的效率也没直接传回常量地址来的快。还有一个更好的做法是直接传回一个指向const type的指针。

指针并不是泄漏内部资料之handle的唯一途径,references也很容易被误用:

class string {
public:
...
char& operator[](int index) const
{ return data[index]; }
private:
char *data;
};
string s = "i'm not constant";
s[0] = 'x'; // 正确, s不是const
const string cs = "i'm constant";
cs[0] = 'x'; // 修改了const string, 但编译器并不会察觉

这种问题的一般解法和先前的指针问题解法一样,要不让函数成为non-const,要不重写函数让它不传回任何handle。至于如何撰写operator[],见条款21。

const member function并不是唯一需要担心“传回handle”的函数,即使non-const member functions,也必须面对这个事实:handle有效性存在期可能比client预期的还短,如下例:

String somefamousauthor() { // 随机选择一个作家名并返回之
switch (rand() % 3) { // rand()在中(还有。参见条款49)
case 0:
return "margaret mitchell"; // 此作家曾写了 "飘",一部绝对经典的作品
case 1:
return "stephen king"; // 他的小说使得许多人彻夜不眠
case 2:
return "scott meyers"; // 嗯...滥竽充数的一个
}
return ""; // 程序不会执行到这儿, 但对于一个有返回值的函数来说,任何执行途径上都要有返回值
}

该函数传回的是一个暂时对象(注意是String,个人定义的一个类,不是string,我尝试用string做实验,发现完全不用担心这回事)。暂时对象寿命短暂,其生命只维持到“调用该函数”之表达式结束为止。这里我做了一个实验,貌似跟书里说的有点诧异,请会的大牛指教:

class X{
public:
X(int x){ this->x=x;}
int x;
};
X* f(){
return &X(5);
}
int main(){
const X *px=f();
cout<<(*px).x; //这里很正常的输出了5 cout<<(*px).x; //紧接着这句就不知道输出的什么东西了 }

因为无法使用标准库里提供的string实验,所以就自己随便写个试试,但是不知道是什么情况。。。
还是把书中的例子写出来吧,不然不好往下进行了。。。

class String {
public:
String(const char *value){
if(value){
data = new char[strlen(value)+1];
strcpy(data,value);
}else{
data=new char[1];
*data='\0';
}
}; // 具体实现参见条款11
~String(){delete []data;}; // 构造函数的注解参见条款m5
char& operator[](int index)const{
return data[index];
}
operator const char *() const; // 转换string -> char*;
// 参见条款m5

private:
char *data;
};
inline String::operator const char *() const{
return data;
}
String f(){
return "hello";
}
int main(){
const char* pc=f();
cout<

这个pc事实上未有定义,这个困难起源于初始化期间:
1)、产生一个暂时性的String object,用来放置f的传回值。
2)、经由String 的operator const char*member function,上述暂时对象被转换为一个const char*,而pc设置为转换后的地址的值。
3)、这个暂时对象被销毁,剩下的就很明白了,pc指向了一个被销毁的空间,这时候一使用pc,程序就翘辫子。。。这便是短暂对象带来的危险。

因此,const member functions传回handles是不好的行为,因为他违反了抽象性(abstraction)。甚至对non-const member functions而言,传回handles也会导致麻烦,特别是涉及暂时对象时。Handles可能悬空(dangling),就像指针一样,尽量避免dangling pointers,也尽量避免dangling handles。但是条款是条款,请不要太绝对化。

条款30:避免出现member functions,传回一个non-const pointer或reference并以之指向较低存取级的members
之前总是利用reference进行提高程序效率,但也说过了,不能简单高效的太过分了,如果reference暴漏了你的private成员或者你不想让别人控制的成员,那么这种高效将带给你很多麻烦。同样指针也可能玩出这样的事情,并且它不仅仅能暴漏data members,更能暴漏function members。
但是有一天你又想保护自己的成员,又想提高效率,这时候请看条款21,尽量使用const。

条款31:千万不要传回“函数内local对象的reference”或“函数内以new获得的指针所指的对象”
这个条款说的貌似之前都有讲到过了,再复习一下好了。比如:(简单写,看到没定义的变量请容易它,并自己想象出!)

inline const Rational& operator*(const Rational& lhs,constRational &rhs){
Rational result(lhs.n * rhs.n,lhs.d*rhs.d);
return result;
}//这个错误很明显了,result脱离了生存scope,自然就消亡了。所以这个传回引用也没作用。
//另一个错误版本:
inline const Rational& operator*(const Rational& lhs,constRational &rhs){
Rational *result=new Rational(lhs.n * rhs.n,lhs.d*rhs.d);
return *result;
}//这个错误倒不是引用问题了,而是你确定你的clients不会忘记删除这个new出来的对象吗?不会造 //成memory leak吗?就算你的clients记得删除,但是如这样product=one*two*three;这样如何删?

如果还试图结果这样的传回引用,请阅读条款23,在你还没有被自己弄抓狂之前。
解决之道就是,请直接返回一个必须需要的对象。

条款32:尽可能延缓变量定义是的出现
正如我所知道的,你的编程方式优雅而不失老练。所以你可能会在想,你决不会定义一个无用的变量,所以本条款的建议不适用于你严谨紧凑的编程风格。但别急,看看下面这个函数:当口令够长时,它返回口令的加密版本;当口令太短时,函数抛出logic_error类型的异常(logic_error类型在C++标准库中定义,参见条款49):

// 此函数太早定义了变量"encrypted"
string encryptPassword(const string& password){
string encrypted;
if (password.length() < MINIMUM_PASSWORD_LENGTH) { throw logic_error("Password is too short"); } 进行必要的操作,将口令的加密版本放进encrypted之中; return encrypted; }

对象encrypted在函数中并非完全没用,但如果有异常抛出时,就是无用的。但是,即使encryptPassword抛出异常(见条款M15),程序也要承担encrypted构造和析构的开销。所以,最好将encrypted推迟到确实需要它时才定义:

// 这个函数推迟了encrypted的定义,
// 直到真正需要时才定义
string encryptPassword(const string& password){
if (password.length() < MINIMUM_PASSWORD_LENGTH) { throw logic_error("Password is too short"); } string encrypted; 进行必要的操作,将口令的加密版本 放进encrypted之中; return encrypted; }

这段代码还不是那么严谨,因为encrypted定义时没有带任何初始化参数。这将导致它的缺省构造函数被调用。大多数情况下,对一个对象首先做的一件事是给它一个什么值,这通常用赋值来实现。条款12说明了为什么"缺省构造一个对象然后对它赋值"比"用真正想要的值来初始化这个对象"效率要低得多。这一论断在此一样适用。例如,假设encryptPassword中最难处理的部分在这个函数中进行:

void encrypt(string& s); // s在此加密

于是encryptPassword可以象这样实现(当然,它不是最好的实现方式):

// 这个函数推迟了encrypted的定义,
// 直到需要时才定义,但还是很低效
string encryptPassword(const string& password){
... // 同上,检查长度
string encrypted; // 缺省构造encrypted
encrypted = password; // 给encrypted赋值
encrypt(encrypted);
return encrypted;
}

更好的方法是用password来初始化encrypted,从而绕过了对缺省构造函数不必要的调用:

// 定义和初始化encrypted的最好方式
string encryptPassword(const string& password){
... // 检查长度
string encrypted(password); // 通过拷贝构造函数定义并初始化
encrypt(encrypted);
return encrypted;
}

这段代码阐述了本条款的标题中"尽可能"这三个字的真正含义。你不仅要将变量的定义推迟到必须使用它的时候,还要尽量推迟到可以为它提供一个初始化参数为止。这样做,不仅可以避免对不必要的对象进行构造和析构,还可以避免无意义的对缺省构造函数的调用。而且,在对变量进行初始化的场合下,变量本身的用途不言自明,所以在这里定义变量有益于表明变量的含义。还记得在C语言中的做法吗?每个变量的定义旁最好要有一条短注释,以标明这个变量将来做什么用。而现在,一个合适的名字(见条款28),再结合有意义的初始化参数,你就可以实现每个程序员的梦想:通过可靠的变量本身来消除对它不必要的注释。

推迟变量定义可以提高程序的效率,增强程序的条理性,还可以减少对变量含义的注释。看来是该和那些开放式模块的变量定义吻别了。

条款33:明智的运用inlining
总的来说inline适用函数体很小的函数。函数体大的函数就算你使用了inline关键字,编译器也会判断是否使用inline比较划算,因为编译器会判断到底inline比较高效,还是non-inline比较好。
使用inline需要注意代码膨胀问题。
另外constructor和destructor不适合inline,即便函数体内什么都没有,但是整个constructor或destructor有很多编译器加入的内容是你无法看到的。
还有一个重要的问题就是你所有用过inline实现的程序,如果改变inline函数,那么你需要重新编译所有使用过这个inline函数的程序。

条款34:将文件之间的编译依赖关系(compilation dependencies)降至最低
这个条款需要开发经验比较丰富的人看,我仔细看了看还是感觉比较云里雾里的。全弄出来以防理解错误误导。

假设某一天你打开自己的C++程序代码,然后对某个类的实现做了小小的改动。提醒你,改动的不是接口,而是类的实现,也就是说,只是细节部分。然后你准备重新生成程序,心想,编译和链接应该只会花几秒种。毕竟,只是改动了一个类嘛!于是你点击了一下"Rebuild",或输入make(或其它类似命令)。然而,等待你的是惊愕,接着是痛苦。因为你发现,整个世界都在被重新编译、重新链接!

当这一切发生时,你难道仅仅只是愤怒吗?

问题发生的原因在于,在将接口从实现分离这方面,C++做得不是很出色。尤其是,C++的类定义中不仅包含接口规范,还有不少实现细节。例如:

class Person {
public:
Person(const string& name, const Date& birthday,
const Address& addr, const Country& country);
virtual ~Person();

... // 简化起见,省略了拷贝构造 函数和赋值运算符函数
string name() const;
string birthDate() const;
string address() const;
string nationality() const;

private:
string name_; // 实现细节
Date birthDate_; // 实现细节
Address address_; // 实现细节
Country citizenship_; // 实现细节
};

这很难称得上是一个很高明的设计,虽然它展示了一种很有趣的命名方式:当私有数据和公有函数都想用某个名字来标识时,让前者带一个尾部下划线就可以区别了。这里要注意到的重要一点是,Person的实现用到了一些类,即string, Date,Address和Country;Person要想被编译,就得让编译器能够访问得到这些类的定义。这样的定义一般是通过#include指令来提供的,所以在定义Person类的文件头部,可以看到象下面这样的语句:

#include // 用于string类型 (参见条款49)
#include "date.h"
#include "address.h"
#include "country.h"

遗憾的是,这样一来,定义Person的文件和这些头文件之间就建立了编译依赖关系。所以如果任一个辅助类(即string, Date,Address和Country)改变了它的实现,或任一个辅助类所依赖的类改变了实现,包含Person类的文件以及任何使用了Person类的文件就必须重新编译。对于Person类的用户来说,这实在是令人讨厌,因为这种情况用户绝对是束手无策。

那么,你一定会奇怪为什么C++一定要将一个类的实现细节放在类的定义中。例如,为什么不能象下面这样定义Person,使得类的实现细节与之分开呢?

class string; // "概念上" 提前声明string 类型详见条款49
class Date; // 提前声明
class Address; // 提前声明
class Country; // 提前声明
class Person {
public:
Person(const string& name, const Date& birthday,
const Address& addr, const Country& country);
virtual ~Person();
... // 拷贝构造函数, operator=
string name() const;
string birthDate() const;
string address() const;
string nationality() const;
};

如果这种方法可行的话,那么除非类的接口改变,否则Person 的用户就不需要重新编译。大系统的开发过程中,在开始类的具体实现之前,接口往往基本趋于固定,所以这种接口和实现的分离将大大节省重新编译和链接所花的时间。

可惜的是,现实总是和理想相抵触,看看下面你就会认同这一点:

int main(){
int x; // 定义一个int
Person p(...); // 定义一个Person(为简化省略参数)
...
}

当看到x的定义时,编译器知道必须为它分配一个int大小的内存。这没问题,每个编译器都知道一个int有多大。然而,当看到p的定义时,编译器虽然知道必须为它分配一个Person大小的内存,但怎么知道一个Person对象有多大呢?唯一的途径是借助类的定义,但如果类的定义可以合法地省略实现细节,编译器怎么知道该分配多大的内存呢?

原则上说,这个问题不难解决。有些语言如Smalltalk,Eiffel和Java每天都在处理这个问题。它们的做法是,当定义一个对象时,只分配足够容纳这个对象的一个指针的空间。也就是说,对应于上面的代码,他们就象这样做:

int main(){
int x; // 定义一个int
Person *p; // 定义一个Person指针
...
}

你可能以前就碰到过这样的代码,因为它实际上是合法的C++语句。这证明,程序员完全可以自己来做到 "将一个对象的实现隐藏在指针身后"。

下面具体介绍怎么采用这一技术来实现Person接口和实现的分离。首先,在声明Person类的头文件中只放下面的东西:

// 编译器还是要知道这些类型名,
// 因为Person的构造函数要用到它们
class string; // 对标准string来说这样做不对,原因参见条款49
class Date;
class Address;
class Country;
// 类PersonImpl将包含Person对象的实
// 现细节,此处只是类名的提前声明
class PersonImpl;
class Person {
public:
Person(const string& name, const Date& birthday,
const Address& addr, const Country& country);
virtual ~Person();
... // 拷贝构造函数, operator=
string name() const;
string birthDate() const;
string address() const;
string nationality() const;
private:
PersonImpl *impl; // 指向具体的实现类
};

现在Person的用户程序完全和string,date,address,country以及person的实现细节分家了。那些类可以随意修改,而Person的用户却落得个自得其乐,不闻不问。更确切的说,它们可以不需要重新编译。另外,因为看不到Person的实现细节,用户不可能写出依赖这些细节的代码。这是真正的接口和实现的分离。

分离的关键在于,"对类定义的依赖" 被 "对类声明的依赖" 取代了。所以,为了降低编译依赖性,我们只要知道这么一条就足够了:只要有可能,尽量让头文件不要依赖于别的文件;如果不可能,就借助于类的声明,不要依靠类的定义。其它一切方法都源于这一简单的设计思想。

下面就是这一思想直接深化后的含义:

· 如果可以使用对象的引用和指针,就要避免使用对象本身。定义某个类型的引用和指针只会涉及到这个类型的声明。定义此类型的对象则需要类型定义的参与。

· 尽可能使用类的声明,而不使用类的定义。因为在声明一个函数时,如果用到某个类,是绝对不需要这个类的定义的,即使函数是通过传值来传递和返回这个类:

class Date; // 类的声明
Date returnADate(); // 正确 ---- 不需要Date的定义
void takeADate(Date d);

当然,传值通常不是个好主意(见条款22),但出于什么原因不得不这样做时,千万不要还引起不必要的编译依赖性。

如果你对returnADate和takeADate的声明在编译时不需要Date的定义感到惊讶,那么请跟我一起看看下文。其实,它没看上去那么神秘,因为任何人来调用那些函数,这些人会使得Date的定义可见。"噢" 我知道你在想,"为什么要劳神去声明一个没有人调用的函数呢?" 不对!不是没有人去调用,而是,并非每个人都会去调用。例如,假设有一个包含数百个函数声明的库(可能要涉及到多个名字空间----参见条款28),不可能每个用户都去调用其中的每一个函数。将提供类定义(通过#include 指令)的任务从你的函数声明头文件转交给包含函数调用的用户文件,就可以消除用户对类型定义的依赖,而这种依赖本来是不必要的、是人为造成的。

· 不要在头文件中再(通过#include指令)包含其它头文件,除非缺少了它们就不能编译。相反,要一个一个地声明所需要的类,让使用这个头文件的用户自己(通过#include指令)去包含其它的头文件,以使用户代码最终得以通过编译。一些用户会抱怨这样做对他们来说很不方便,但实际上你为他们避免了许多你曾饱受的痛苦。事实上,这种技术很受推崇,并被运用到C++标准库(参见条款49)中;头文件就包含了iostream库中的类型声明(而且仅仅是类型声明)。

Person类仅仅用一个指针来指向某个不确定的实现,这样的类常常被称为句炳类(Handle class)或信封类(Envelope class)。(对于它们所指向的类来说,前一种情况下对应的叫法是主体类(Body class);后一种情况下则叫信件类(Letter class)。)偶尔也有人把这种类叫 "Cheshire猫" 类,这得提到《艾丽丝漫游仙境》中那只猫,当它愿意时,它会使身体其它部分消失,仅仅留下微笑。

你一定会好奇句炳类实际上都做了些什么。答案很简单:它只是把所有的函数调用都转移到了对应的主体类中,主体类真正完成工作。例如,下面是Person的两个成员函数的实现:

#include "Person.h" // 因为是在实现Person类,所以必须包含类的定义
#include "PersonImpl.h" // 也必须包含PersonImpl类的定义,
// 否则不能调用它的成员函数。 注意PersonImpl和Person含有一样的 成员函数,它们的接口完全相同
Person::Person(const string& name, const Date& birthday,
const Address& addr, const Country& country){
impl = new PersonImpl(name, birthday, addr, country);
}
string Person::name() const{
return impl->name();
}

请注意Person的构造函数怎样调用PersonImpl的构造函数(隐式地以new来调用,参见条款5和M8)以及Person::name怎么调用PersonImpl::name。这很重要。使Person成为一个句柄类并不改变Person类的行为,改变的只是行为执行的地点。

除了句柄类,另一选择是使Person成为一种特殊类型的抽象基类,称为协议类(Protocol class)。根据定义,协议类没有实现;它存在的目的是为派生类确定一个接口(参见条款36)。所以,它一般没有数据成员,没有构造函数;有一个虚析构函数(见条款14),还有一套纯虚函数,用于制定接口。Person的协议类看起来会象下面这样:

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

Person类的用户必须通过Person的指针和引用来使用它,因为实例化一个包含纯虚函数的类是不可能的(但是,可以实例化Person的派生类----参见下文)。和句柄类的用户一样,协议类的用户只是在类的接口被修改的情况下才需要重新编译。

当然,协议类的用户必然要有什么办法来创建新对象。这常常通过调用一个函数来实现,此函数扮演构造函数的角色,而这个构造函数所在的类即那个真正被实例化的隐藏在后的派生类。这种函数叫法挺多(如工厂函数(factory function),虚构造函数(virtual constructor)),但行为却一样:返回一个指针,此指针指向支持协议类接口(见条款M25)的动态分配对象。这样的函数象下面这样声明:

// makePerson是支持Person接口的
// 对象的"虚构造函数" ( "工厂函数")
Person*
makePerson(const string& name, // 用给定的参数初始化一个
const Date& birthday, // 新的Person对象,然后
const Address& addr, // 返回对象指针
const Country& country);

用户这样使用它:

string name;
Date dateOfBirth;
Address address;
Country nation;
...
// 创建一个支持Person接口的对象
Person *pp = makePerson(name, dateOfBirth, address, nation);
...
cout << pp->name() // 通过Person接口使用对象
<< " was born on " << pp->birthDate()
<< " and now lives at " << pp->address();
...

delete pp; // 删除对象

makePerson这类函数和它创建的对象所对应的协议类(对象支持这个协议类的接口)是紧密联系的,所以将它声明为协议类的静态成员是很好的习惯:

class Person {
public:
... // 同上
// makePerson现在是类的成员
static Person * makePerson(const string& name,
const Date& birthday,
const Address& addr,
const Country& country);

这样就不会给全局名字空间(或任何其他名字空间)带来混乱,因为这种性质的函数会很多(参见条款28)。

当然,在某个地方,支持协议类接口的某个具体类(concrete class)必然要被定义,真的构造函数也必然要被调用。它们都背后发生在实现文件中。例如,协议类可能会有一个派生的具体类RealPerson,它具体实现继承而来的虚函数:

class RealPerson: public Person {
public:
RealPerson(const string& name, const Date& birthday,
const Address& addr, const Country& country)
: name_(name), birthday_(birthday),
address_(addr), country_(country)
{}

virtual ~RealPerson() {}

string name() const; // 函数的具体实现没有
string birthDate() const; // 在这里给出,但它们
string address() const; // 都很容易实现
string nationality() const;

private:
string name_;
Date birthday_;
Address address_;
Country country_;

有了RealPerson,写Person::makePerson就是小菜一碟:

Person * Person::makePerson(const string& name,
const Date& birthday,
const Address& addr,
const Country& country){
return new RealPerson(name, birthday, addr, country);
}

实现协议类有两个最通用的机制,RealPerson展示了其中之一:先从协议类(Person)继承接口规范,然后实现接口中的函数。另一种实现协议类的机制涉及到多继承,这将是条款43的话题。

是的,句柄类和协议类分离了接口和实现,从而降低了文件间编译的依赖性。"但,所有这些把戏会带来多少代价呢?",我知道你在等待罚单的到来。答案是计算机科学领域最常见的一句话:它在运行时会多耗点时间,也会多耗点内存。

句柄类的情况下,成员函数必须通过(指向实现的)指针来获得对象数据。这样,每次访问的间接性就多一层。此外,计算每个对象所占用的内存大小时,还应该算上这个指针。还有,指针本身还要被初始化(在句柄类的构造函数内),以使之指向被动态分配的实现对象,所以,还要承担动态内存分配(以及后续的内存释放)所带来的开销 ---- 见条款10。

对于协议类,每个函数都是虚函数,所有每次调用函数时必须承担间接跳转的开销(参见条款14和M24)。而且,每个从协议类派生而来的对象必然包含一个虚指针(参见条款14和M24)。这个指针可能会增加对象存储所需要的内存数量(具体取决于:对于对象的虚函数来说,此协议类是不是它们的唯一来源)。

最后一点,句柄类和协议类都不大会使用内联函数。使用任何内联函数时都要访问实现细节,而设计句柄类和协议类的初衷正是为了避免这种情况。

但如果仅仅因为句柄类和协议类会带来开销就把它们打入冷宫,那就大错特错。正如虚函数,你难道会不用它们吗?(如果回答不用,那你正在看一本不该看的书!)相反,要以发展的观点来运用这些技术。在开发阶段要尽量用句柄类和协议类来减少 "实现" 的改变对用户的负面影响。如果带来的速度和/或体积的增加程度远远大于类之间依赖性的减少程度,那么,当程序转化成产品时就用具体类来取代句柄类和协议类。希望有一天,会有工具来自动执行这类转换。

有些人还喜欢混用句柄类、协议类和具体类,并且用得很熟练。这固然使得开发出来的软件系统运行高效、易于改进,但有一个很大的缺点:还是必须得想办法减少程序重新编译时消耗的时间。

我猜你可能也喜欢:

No Comments - Leave a comment

Leave a comment

电子邮件地址不会被公开。 必填项已用*标注

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>


Welcome , today is 星期二, 2017 年 10 月 24 日