实现
大多数情况下,设计类以及函数是花费精力最多的两件事。一旦设计正确,相应的实现大多直截了当。但对于实现,依然有许多需要注意的问题。
条款26:尽可能延后变量定义式的出现时间
只要我们定义了一个类类型的变量,那么当程序的控制流到达这个变量时,便要承受构造它的成本;当程序的控制流离开这个变量时,便要承受析构它的成本,即使这个变量没有被使用过。我们应该尽量避免这些情况。
合理的做法就是 尽可能延后变量定义式的出现,直到非要使用这个变量的前一刻,甚至直到能赋予这个变量初值的前一刻。这样既可以避免定义不被使用的变量,又可以避免调用默认构造函数再进行赋值的时间消耗。
条款27:尽量少做转型动作
C++的设计目标之一是,保证“类型错误”绝不发生。我们先回顾一下转型语法,通常有三种不同的形式。
(T)exp; //将exp转型为T,C风格的转型
T(exp); //将exp转型为T,函数风格的转型
//C++四种新式转型
const_cast<T> (exp); //常量性转除
dynamic_cast<T> (exp); //安全向下转型
reinterpret_cast<T> (exp);
static_cast<T> (exp); //强制性隐式转换
- dynamic_cast 用于执行安全向下转型,也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法用其他两种形式代替的动作,也是唯一可能耗费巨大运行成本的转型动作。
- static_cast 用于强迫隐式转换,例如将int转换为double,non-const转换为const,void*转换为type,pointer-to-base转换为pointer-to-derived等等。
- const_cast 用于常量性转除,将一个常量转为一个非常量
- reinterpret_cast 用于执行低级转型
我们最好养成使用C++新式转型的习惯,不仅可以快速定位Bug,看起来也会简单清晰。
我们可能会认为,类型转换其实什么都没有做,只是让编译器把某种类型视为另一种类型,这是错的。实际上,不管是显示还是隐式类型转换,编译器真的会编译出在运行期间执行的码。
class Person{};
class Fancy:public Person{};
Fancy a;
Person* b = &a;
cout << &a <<endl << b << endl;
在上述代码中,值得注意的是,根据编译器的不同,对象的布局方式和它们的地址计算方式也不同。很有可能&a和b的值并不相同,也就是一个对象拥有一个以上的地址,因为这种情况下会有一个偏移量被实施于Derived*身上,用以取得正确的Base*。
再者需要注意的是,类型转换生成一个转换后的临时变量,而被转换的变量不会做修改。
class Window{
public:
virtual void onResize();
};
class SpecialWindow:public Window{
public:
virtual void onResize()
{
static_cast<Window>(*this).onResize(); //此处是Window的副本调用函数
//换为 Window::onResize();
...
}
};
上述代码看起来好像是对的,但实际上有很大问题。我们将派生类对象显示转换为基类对象,生成了一个由派生类的基类部分构造的基类对象副本,从而调用副本的函数,若函数做了什么修改,那么对象本身没有被修改,而是修改的副本。
另外需要注意的是,dynamic_cast的执行速度相当的慢,它的一个普遍实现版本基于class名称字符串的比较,如果我们在一个四层深的单继承体系内的某个对象使用dynamic,可能会耗用四次的strcmp调用用以比较class名称。
当我们利用基类指针或引用访问派生类对象的函数时,对于基类和派生类的同名函数,我们可以将它们定义为虚函数,来实现运行时绑定。
但如果是派生类特有的函数,我们该如何访问呢?在基类中声明一个函数体为空的同名虚函数是可行的,但如果特有的函数很多,这个方案就不太好。这时候就需要dynamic_cast 解决 利用基类的指针或引用访问派生类对象的特有函数,由此也可得出,dynamic_cast是对指针和引用的转型,它的参数只能是引用或指针。
enum Type
{
TYPE_PERSON,
TYPE_STUDENT,
TYPE_TEACHER
};
class Person
{
protected:
int age;
int money;
public:
virtual Type GetType() { return Type::TYPE_PERSON; }
};
class Student : public Person
{
public:
virtual Type GetType() override { return Type::TYPE_STUDENT; }
void DoHomework(){}
};
class Teacher : public Person
{
public:
virtual Type GetType() override { return Type::TYPE_TEACHER; }
void DoOther(){}
};
Student* a = new Student();
Teacher* b = new Teacher();
Student* c = new Student;
vector<Person*> vec;
vec.push_back(a);
vec.push_back(b);
vec.push_back(c);
for (int32_t i = 0; i < vec.size(); ++i)
{
if (vec[i]->GetType() == Type::TYPE_STUDENT)
{
Student& s = dynamic_cast<Student&>(*vec[i]);
s.DoHomework();
}
}
总结:
- 尽量不要使用类型转换
- 类型转换返回的是被转换了的变量副本,而原变量没有做改变;对指针而言,转换指针只会生成一个指针的副本,指向原变量;对引用而言,不会产生副本。
- dynamic_cast用于处理用基类的指针和引用访问派生类对象的特有函数,它的参数只能是引用或指针。
条款28:避免返回handles指向对象内部成分
引用、指针、迭代器统统都是所谓的handles,而返回一个“代表对象内部成分”的handles,随之而来的便是“降低对象封装性”的风险。
class Point{ //表示坐标点
public:
Point(int x,inty);
void setX(int x);
void setY(int y);
};
struct RectData{ //表示窗口的左上坐标和右下坐标
Point ulhc;
Point lrhc;
};
class Rectangle{ // 窗口类
public:
Point& upperLeft()const{ return pData->ulhc; }
Point& lowerRight()const{ return pData->rlhc; }
private:
shared_ptr<RectData> pData;
};
在上述代码中,窗口类的两个公有函数,获得窗口的左上角坐标与右下角坐标,看起来非常合理,但是其中存在着潜在的风险。这两个函数为const,而返回的是对象内部数据的引用,相当于暴露给了外部一个可以改变该数据的接口,这既不符合const的初衷,也不符合封装性的实现。
const Point& upperLeft()const{ return pData->ulhc; }
const Point& lowerRight()const{ return pData->rlhc; }
改为上述代码后,使函数符合const的初衷,但是仍有潜在的风险。
class GUIObject{ ... };
const Rectangle boundingBox(const GUIObject& obj);
GUIObject *pgo;
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
我们按照以上方式调用upperLeft()函数,根据pgo得到一个Rectangle的临时副本,并调用该副本的upperLeft函数获得左上角的坐标点,令指针指向该坐标点。该临时副本在使用结束后就会被销毁,这会导致左上角的坐标点也被析构,此时指针指向的是一个被销毁的对象!
总结:
- 尽量避免返回指针、引用、迭代器指向对象内部。遵守这个条款可以增加封装性、帮助const成员函数的行为更像const、将野指针的可能降到最低。
条款29:为“异常安全”而努力是值得的
假设有个class用来表现有背景图案的GUI菜单。这个类希望用于多线程环境,所以它有个互斥器mutex作为并发控制之用。
class PrettyMenu{
public:
void changeBackground(istream& imgSrc); //改变背景图像
private:
Mutex mutex;
Image* bgImage; //背景图像
int imageChanges; //背景图像改变的次数
};
void PrettyMenu::changeBackground(istream& imgSrc)
{
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
从“异常安全性”来看,这个函数很糟,这个函数没有满足其中任何一个条件。
当异常被抛出时,带有异常安全性的函数会:
- 不泄露任何资源。一旦new Image导致异常,那么这个锁mutex永远不会被关上。
- 不允许数据破坏。一旦newImage导致异常,bgImage会指向一个已被删除的对象,并且ImageChanges被增加。
解决资源泄露的问题很容易,因为条款13讨论过如何以对象管理资源。
void PrettyMenu::changeBackground(istream& imgSrc)
{
Lock m1(&mutex); //使用资源管理类管理互斥锁,确保一定会开锁
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
接下来我们专心解决数据破坏的问题。在此之前,我们需要问问自己,我们的函数面对异常安全需要做到以下三种情况的哪一种?
-基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构因此被破坏。然而程序的状态是无法预及的。例如上述代码中,我们调用changeBackground而抛出了异常,对象可以拥有原有的背景,也可以是某个缺省的背景,客户无法知道是哪一种而必须调用成员函数获得背景。
-强烈保证: 如果异常被抛出,程序状态不改变。也就是说,如果函数调用成功,就是按原预期进行;如果函数调用失败,程序会回到调用之前的状态。
-nothrow: 承诺绝不抛出异常。即总是能够完成原先承诺的内容。
对于大多数函数而言,承诺绝不抛出异常实在是难以实现,抉择往往落在前两种选择之间。一般而言,最好实现强烈保证。
class PrettyMenu{
public:
void changeBackground(istream& imgSrc);
private:
Mutex mutex;
shared_ptr<Image> bgImage; //将背景图像设置为智能指针
int imageChanges;
};
void PrettyMenu::changeBackground(istream& imgSrc)
{
Lock m1(&mutex); //管理锁资源
bgImage.reset(new Image(imgSrc)); //管理图像资源
++imageChanges;
}
在上述代码中,我们将背景图像资源由普通指针换为智能指针,为了更好的进行资源管理。对于shared_ptr类的reset函数,当参数被成功构造时才会执行reset,参数构造失败则不执行。也就是执行失败则是失败之前的状态。
我们有一个一般化的设计copy and swap来实现强烈保证。也就是copy要修改的对象,对副本做出修改,若修改期间没有异常,则在结束时将两个对象swap。
struct Pm{
shared_ptr<Image> bgImage; //将背景图像设置为智能指针
int imageChanges;
};
class PrettyMenu{
public:
void changeBackground(istream& imgSrc);
private:
Mutex mutex;
shared_ptr<Pm> p;
};
void PrettyMenu::changeBackground(istream& imgSrc)
{
Lock m1(&mutex);
shared_ptr<Pm> temp(new Pm(*p)); //拷贝要修改的部分
temp->bgImages.reset(new Image(imgSrc)); //对副本作出修改
temp->imageChanges++;
using std::swap;
swap(temp,p); //交换两个对象
}
我们无法保证copy and swap一定有强烈的异常安全性。如下代码,如果f1或f2的异常安全性比强烈保证低,那么somFuc很难是一个强烈保证的函数;如果f1和f2都是强烈保证函数,当f1成功运行时,或许一些数据已经被改变,因此如果f2无法运行成功,程序也不可能回到调用someFuc之前的状态。
void someFuc{
... //copy
f1();
f2();
... //swap
}
因此,当f1或f2函数修改的是非局部对象时,函数someFun的安全性是无法保证的。并且copy and swap会浪费许多的时间和空间。我们必须要为函数的异常安全考虑,在一个系统中,若有一个函数是不安全的,那么整个系统都是不安全的。因此,我们必须在实际条件允许的情况下致力实现基本承诺、强烈保证、nothrow中的一种。
总结:
- 异常安全函数即使发生异常也不会泄露资源和破坏数据。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
- 强烈保证往往能够以copy and swap实现出来,但强烈保证并非对所有函数都可实现或具备实现意义。
条款30:透彻了解inlining的里里外外
inline函数,它们看起来像函数,动作像函数,比宏好得多,又不用承受函数调用的开销,并且编译器会为它们执行最优化,看起来是再好不过。
然而,它是将代码进行文本替换,那么程序的目标码会大很多,在内存很小的机器上是很不好的,哪怕有虚存也会增加换页的开销、降低cache的命中率。
因此,我们要谨慎使用inline函数。当函数的本体很小,针对函数本体产生的目标码 比 函数调用的码还小,这个时候使用inline再好不过。
inline函数往往被定义在头文件内。因为大多数编译器会在编译期对函数进行inlining,这就要求在编译期必须知道inline函数的本体,因此要将它置于头文件内。
inline只是对编译器的一个申请,定义在类内的函数(不管是成员函数还是友元函数)都被隐喻的声明为inline,而我们也可用关键字inline显示声明内联函数。而实际上,一个函数到底是不是inline取决于你的build环境和编译器,编译器往往会拒绝体积很大的函数的inline申请,也会拒绝虚函数的inline申请。
若是编译器接受了某个inline函数的申请,当我们要取这个函数的地址时,编译器依然会为它生成一个函数本体。
构造函数和析构函数往往是inline函数的糟糕候选人。如下述代码所示,Derived()构造函数函数体为空,看起来很适合作为inline函数。但实际不是这样的。
class Base{
public:
...
private:
string s1,s2;
};
class Derived:public Base{
public:
Derived(){}
...
private:
string s1,s2,s3;
}
C++为“对象被创建和被销毁时发生什么事”做了各式各样的保证。当我们new一个对象时自动调用其构造函数,delete时自动调用其析构函数;当我们调用派生类构造函数时,自动调用基类构造函数,当我们调用派生类析构函数时,自动调用基类析构函数;当构造对象期间抛出异常,那么构造好的部分会被销毁……那么这些行为是被放在哪里的呢?有时候就放在构造函数和析构函数里。
比如上述代码中那个看起来为空的Derived()有可能就是这样的:
Derived::Derived()
{
Base::Base();
try{s1.string();}
catch(...){
Base::~Base();
throw;
}
try{s2.string();}
catch(...){
s1.~string();
Base::~Base();
throw;
}
try{s3.string();}
catch(...){
s1.~string();
s2.~string();
Base::~Base();
throw;
}
}
而若是我们将基类的构造函数声明为inline,那么所有调用基类构造函数的地方(如派生类构造函数)同样会展开大量代码。若string构造函数为Inline,那么在上述代码中,将会有三份string()代码嵌入。