C++的55个条款——实现

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/fancynece/article/details/79760541

实现

大多数情况下,设计类以及函数是花费精力最多的两件事。一旦设计正确,相应的实现大多直截了当。但对于实现,依然有许多需要注意的问题。


条款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()代码嵌入。


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

猜你喜欢

转载自blog.csdn.net/fancynece/article/details/79760541
今日推荐