当前位置: 移动技术网 > IT编程>开发语言>C/C++ > EffectiveC++笔记 第5章

EffectiveC++笔记 第5章

2018年05月30日  | 移动技术网IT编程  | 我要评论

cz3901航班,幽幽夏季,无底门

我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。

Chapter 5 实现 Implementations

适当提出属于你的class定义以及各种functions声明相当花费心思。一旦正确完成它们,相应的实现大多直截了当。尽管如此,还是要小心很多细节。


条款26 : 尽可能延后变量定义式的出现时间

当你定义了一个变量,其类型带有构造函数和析构函数,当程序控制流(control flow)到达此变量定义式时,你需要承担构造成本;此变量离开作用域时,便需要承担析构成本———即使你自始至终都没有用过它。所以你应避免这种情况

你会问:怎么可能定义「不被使用的变量」?下面考虑一个函数,作用是计算通行密码的加密版本后返回,但前提是密码足够长。若太短,函数会抛出异常,类型为logic_error

//此函数过早定义变量“encrypted”
std::string encryptPassword(const std::string& password)
{
    using namespace std;
    string encrypted;
    if(password.length()<MinimumPasswordLength){
        throw logic_error("Password is too short");
    }
    ... //诸如将加密后的密码放进encrypted内的动作
}

这里存在的问题是,如果抛出了异常,那encrypted就真的没被使用———然而你还得付出构造和析构的成本。 看起来较好的解决方案是这样的:

...
if(password.length()<MinimumPasswordLength){
throw logic_error("Password is too short");
}
string encrypted; //延后定义式,直到真正需要它
...

其实效率不够高,因为encrypted虽获定义却无实参作初值。更好的做法是“直接在构造时指定初值”,这样的效率高于default构造函数(构造对象再对它赋值)。<我们在条款4讨论过效率问题>

现在我们一步一步进行分析。假设将函数encryptPassword的加密部分用 void encrypt(std::string& s); 实现,于是encryptPassword实现如下:

//此版本虽延后了定义,但仍效率低下:
std::string encryptPassword(const std::string& password)
{
    ...    //检查length,同前
    std::string encrypted; //default constructor,无意义
    encrypted = password;
    encrypt(encrypted);
    return encrypted;
}

更受欢迎的做法是直接将password作为encrypted初值,跳过无意义默认构造:

std::string encrypted(password);

现在我们大概能理解「尽可能延后」的深层含义:你应尝试延后这份变量定义直到能够给它初值实参为止。

但遇到循环怎么办?若我们只在循环内用到变量,是该将它定义与循环外并在每次循环迭代赋值给它,还是将其定义于循环内? :

//A方案,定义于循环外:       //方法B,定义于循环内 :
Widget w;                  for(int i=0;i<n;++i){
for(int i=0;i<n;++i){          Widget w(表达式取决于i值);
    w = 表达式;(取决于i值)      ...
}                             }

首先看A和B做法的成本:

  • A: 1个构造函数 + 1个析构函数 + n个赋值操作
  • B: n个构造函数 + n个析构函数

我们可以理清:

  1. A的适用情况:
    class的一个赋值成本低于一组构造+析构成本 ;否则做法B较好

  2. 另外,A造成名称w作用域大于B,有潜在对程序可理解性和易维护性的冲突。

结论

除非你知道赋值成本小于“析构+构造” ;
你正在处理代码中对性能高度敏感(performance-sensitive)部分。
否则你该使用做法B。


条款27: 尽量少做转型动作 Minimize casting

很不幸,转型(casts)可能导致各种麻烦,有的显而易见,有的非常隐晦。

让我们复习一下转型的语法:

  • C风格:
    (T)expression 将expression转为T
  • 函数风格:
    T(expression) 将expression转为T

它们并无差别,只是小括号位置不同而已。我们可以称这两种为「旧式转型」(old-style casts)。

C++还提供了四种新式转型:

  1. const_cast
  2. dynamic_cast
  3. reinterpret_cast
  4. static_cast

各有不同用途:

  • const_cast通常用来将对象的常量性质除去(cast away the constness)(不是真正除去)。它是唯一有此能力的C++-style转型操作符。

  • dynamic_cast主要用作“ 安全向下转型 safe downcasting ”,能决定对象是否属于继承体系中某个类型。它是唯一无法用旧式语法执行的动作,并可能耗费大量运行成本。(后面会讨论)

  • reinterpret_cast执行低级转型,实际动作及结果取决于编译器,这意味它不可移植。

  • static_cast用来强迫隐式转换(implicit conversions)。例如将non-const对象转为const对象,将int转为double等。但将const转为non-const只有const_cast做得到

新式转型较受欢迎:

  1. 它们易被辨识(不论人工还是工具)
  2. 可以缩小转型动作的选择范围。比如想去掉常量性(constness),只有const_cast能办到

使用旧式转型一般是调用explicit构造函数将对象传给一个函数:

class Widget{
public:
    explicit Widget(int size);
    ...
};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15));       //函数式
doSomeWork(static_cast<Widget>(15));   //新式

使用第一种的原因可能是你觉得比较这样自然,不像第二种蓄意的“生成对象”。但是为了以后代码的可靠性,还是老老实实用新式转型吧。

另外,C++的指针会产生偏移(offset)现象:

class Base { ... }
class Derived: public Base { ... };
Dervied d;
Base* pb = &d;      //隐式转换

注意上方代码,有时候引用d和指针pb两个指针(d其实相当于常量指针)的值并不相同。这种情况下,在运行期间会有一个偏移量(offset)被施于Dervied* 指针身上,以获取正确的Base*指针值。

上面的论述表明,单一对象(例如这里的Derived对象)可能拥有一个以上的地址(例如对象“以Base* 指向它”时的地址和“以Derived*指向它”时的地址)。其它语言几乎不可能出现这种情况,然而神奇的C++可以!!实际上C++碰到多重继承,这事儿其实一直在发生———即使单一继承也可能发生。

请注意了,对于偏移量(offset),对象布局和它们的地址计算方式随编译器不同而不同————则意味着可能你设计的转型在某平台可用,在另一平台不一定可用!

另一件关于转型的有趣的问题:许多应用框架(application frameworks)要求dervied class内的virtual函数代码第一语句即调用其base class的对应函数

我们很容易写一些看起来很对的转型代码:

class Window{
public:
    virtual void onResize() {...} //基类onResize实现
    ...
};

class SpecialWindow: public Window{ //derived class
public:
    virtual void onResize(){
        static_cast<Window>(*this).onResize();
        /* 试图将*this,即当前对象指针转为父类,然后调用其
           onResize,not ok! */
        ...  //SpecialWindow专属动作
    }
}; 

我们在代码中用了新式转型(实际上用旧式也是有问题的)。 一如你预期,程序确实将* this转型为Window基类,对onResize的调用也因此调用了Window::onResize。但你一定没想到,它调用的并不是当前对象的函数,而是转型动作「早期」,程序建立的一个 ”this类型但只包含其base class Window成分的对象”*的暂时副本身上的onResize!

我们再理解一遍 :上述代码并非「在当前对象身上调用Window::onResize后又在该对象身上执行SpecialWindow专属动作」。不不不,它是「在”当前对象之base class成分”的副本上调用Window ::onResize」,然后才在当前对象上执行SpecialWindow专属动作。会出现啥问题呢??假设onResize函数作用是改变对象的某内容,调用它时,首先转型*this指针为Window然后调用Window的onResize,并对Window成分进行专属操作。 但实际上此时调用的是「含有Window成分」对象副本的onResize,动作根本没有落实到真正的base class成分上;但SpecialWindow的onResize会真的改动原对象!想象一下,这会使当前对象进入“伤残”状态

解决之道是拿掉转型动作,别去哄骗编译器将*this视为一个base class对象:

class SpecialWindow: public Window{
public:
    virtual void onResize(){
        Window::onResize(); //调用Window域的onResize作用于*this
        ...
    }
    ...
};

现在谈谈dynamic_cast。它的许多实现版本执行速度很慢。一个普遍的实现版本基于“class之字符串比较”,如果你在四层深的单继承体系内某对象身上执行dynamic_cast,这个实现版本每层的一次dynamic_cast可能会耗用四次strcmp调用来比较class名称!深度继承和多重继承成本更高。(有些版本为了必须实现的动态链接必须这么做)但你应在注重效率的代码中思量是否要使用dynamic_cast。

通常用到dynamic_cast的场景为:
你想在一个dervied class对象身上执行专属于此之类自己的函数,但你只有一个base class类型的pointer或reference。

有两个一般性做法可以避免窘境:

通过STL直接储存指向dervied class对象的指针(通常为智能指针,见条款13)。

假设之前的Window/SpecialWindow继承体系中,SpecialWindow有专属函数void blink() ,不要这么写代码:

class Window {...};
class SpecialWindow: public Window{
public:
    void blink();
    ...
};
typedef
std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin();
        iter != winPtrs.end();++iter){
    if(SpecialWindow* psw=dynamic_cast<SpecialWindow*>(iter->get()))    //dynamic_cast效率低下
    psw->blink();
}

应这么写:

typedef
std::vector<std::tr1::shared_ptr<SpecialWindow> > VPSW;
VPSW winPtrs;
...
for(VPSW::iterator iter = winPtrs.begin();
        iter!=winPtrs.end();++iter)
    (*iter)->blink();
//不用dynamic_cast的实现

当然,这种写法会使你无法在一个容器内储存「可指向所有base派生类」的指针。若确实需要处理多种类型,可能需要多个容器,他们必须具备类型安全性。

另一种做法可让你通过base class接口处理所有base派生类,那就是在base里提供一个virtual函数。这类似Java里的抽象类:

class Window{
public:
    virtual void blink(){}
    //默认实现代码「什么也没做」,交给子类实现。以后会告诉你这可能是馊主意
    ...
};
class SpecialWindow: public Window{
public:
    virtual void blink() {...};
    //子类的blink里做一些事。
    ...
};

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;  //容器内含base类型指针
...
for(VPW::iterator iter = winPtrs.begin();
        iter!=winPtrs.end();++iter)
    (*iter)->blink();

上述两种方法并非具有强大的普遍性,但是很多时候你应该以此替代dynamic_cast。

有一个你绝对,必须避免的东西:连串(cascading)dynamic_casts。也就是看起来像这样的东西:

class Window {...};
...
typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin();
        iter!=winPtrs.end();++iter)
{
    if(SpecialWindow1* pswl = 
        dynamic_cast<SpecialWindow1*>(iter->get())) {...}
    else if(SpecialWindow2* psw2 = 
        dynamic_cast<SpecialWindow2*>(iter->get())) {...}
    else if(SpecialWindow3* psw3 = 
        dynamic_cast<SpecialWindow3*>(iter->get())) {...}
    ...
}

这样产生的代码又肿又慢,基础不稳。例如一旦加入新的dervied class,上述连串判断可能就要加入新的分支。这样的代码应以“基于virtual函数调用”的东西取代。

优良的C++代码很少使用转型,但完全摆脱转型操作不切实际。

最后,请记住:

  • 尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。
  • 如果转型必要,试着将它隐藏在某个函数后,客户只需调用函数,而不用将具体实现加进他们的代码里。
  • 宁用C++-style(新式)转型。

条款28: 避免返回handles指向对象内部成分

Avoid returning “handles” to object internals

现在假设你的程序涉及矩形,每个矩形由左上角和右下角的点坐标确定。为了让一个Rectangle对象尽可能小,你可能决定把定义的点放在辅助点struct内

class Point{ //此类表示“点”
public:
    Point(int x,int y);
    ...
    void setX(int newVal);
    void setY(int newVal);
    ...
};

struct RectData{ //这些“点”数据用来表现矩形
    Point ulhc;  //"upper left-hand comer"(左上角)
    Point lrhc;  //"lower right-hand comer"(右下角)
};

class Rectangle{
    ...
private:
    std::tr1::shared_ptr<RectData> pData;
};

使用Rectangle的客户需要计算Rectangle范围,所以此类提供upperLeft和lowerRight函数来返回左上角和右下角的坐标。根据条款20的讨论,我们让函数返回引用,代表底层的Point对象:

class Rectangle{
public:
    ...
    Point& upperLeft() const { return pData->ulhc; }
    Point& lowerRight() const { return pData->lrhc; }
    ...
}

这种设计有一个重大缺陷:虽然两个函数被设计为const从而不能修改类成员函数,但是它所返回的reference却可以直接指向private内部数据,例如:

Point coord1(0,0);
Point coord2(100,100);
const Rectangle rec(coord1,coord2); //一个const矩形
rec.upperLeft().setX(50); //??一个const矩形的值竟然被改变了?

伙计,rec应该是不可变的啊!这给我们一个教训:

成员变量的封装性最多等于「返回其reference」的函数的访问级别

如果类似的函数返回指针或迭代器的,相同的事情还是会发生。原因很简单,references、pointers和迭代器统统是所谓的 handles(号码牌,用来取得某对象)。所以返回一个“代表对象内部数据”的handle会带来降低对象封装性的风险。

之前的问题可以通过一个开头的修饰符轻松解决:

const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }

这样一来,返回的引用的权限仅为“只读”。

可即使如此,在其它场合可能还是会有问题。它可能导致_dangling handles(空悬的号码牌);_也就是说handles所指物(的所属对象)不复存在。这种问题常见来源是函数返回值。例如某函数返回GUI对象的外框,这个框采用矩形形式:

class GUIObject {...};
const Rectangle
    boundingBox(const GUIObject& obj);
//以by value形式返回矩形

现在客户可能这么使用此函数:

GUIObject* pgo;
... //让pgo指向某个GUIObject
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
//取得一个指针指向外框左上角坐标

上述的操作中,boundingBox返回一个临时的Rectangle副本对象,它没有名称,暂且称它temp。随后upperLeft作用于temp并返回一个reference指向temp内部的Point成分,然后指针pUpperLeft指向那个Point对象。然鹅。。。在语句结束后,temp将会被销毁,间接导致temp内的Points析构,最终导致pUpperLeft指向不存在的对象。

这就是为啥函数返回一个handle总是危险的原因。但这不是说你绝不能让成员函数返回handles,有时你必须这么做。比如operator[]返回的引用允许你取得string对象或vector对象的个别元素。


条款29: 为“异常安全”而努力是值得的 Strive for exception-safe code.

假设有一个class表现带背景图案的GUI菜单。这个class希望用于多线程环境,所以它有个互斥器(mutex)作为并发控制(concurrency control)之用:

class PrettyMenu{
public:
    ...
    void changeBackground(std::istream& imgSrc); //修改菜单背景
    ...
private:
    Mutex mutex;  //互斥器
    Image* bgImage;  //目前的背景图案
    int imageChanges;  //记录背景被改次数
};
//下面是changeBackground的可能实现

void PrettyMenu::changeBackground(std::istream& imgSrc){
    lock(&mutex);   //取得互斥器(见条款14)
    delete bgImage;  //摆脱旧背景图案
    ++imageChanges;  //增加次数
    bgImage = new Image(imgSrc);  //安装新背景
    unlock(&mutex); //释放互斥器
}

从“异常安全性”的两个条件来看,这个函数很糟:

  1. 不泄漏任何资源 一旦 new Image(imgSrc) 导致异常,对unlock的调用就不会执行,于是互斥器就永远被把持住了。

  2. 不允许数据败坏new Image(imgSrc) 抛出异常,bgImage即指向一个已被删除的对象,imageChanges也被累加,然而其实没有新图像被成功安装。
    - - - -
    解决资源泄漏很容易,以前的条款14曾讨论过,导入Lock class作为一种「确保互斥器几被及时释放」的方法:

void PrettyMenu::changeBackground(std::istream& imgSrc){
    Lock ml(&mutex); //来自条款14
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
}

如Lock流的“资源管理类(resource management classes)”通常能使代码更短。在上例的体现在于省去了unlock函数。
- - - -
接下来解决数据的败坏。

首先,异常安全函数(exception-safe functions)提供以下三个保证之一:

  • 基本承诺: 一旦异常抛出,程序内任何事物仍保持在有效状态下。没有任何对象或数据结构因此败坏,处于前后一致状态。但现实状态(exact state)不可预料,比如我们可以设计changeBackground为,一旦抛出异常,PrettyMenu可继续持有原背景或赋给默认背景图案。

  • 强烈保证: 一旦异常抛出,程序状态不变。这样的函数,只要成功,就完全成功;如果失败,程序会恢复到“调用函数之前”状态。

持续更新中................................

如对本文有疑问,请在下面进行留言讨论,广大热心网友会与你互动!! 点击进行留言回复

相关文章:

验证码:
移动技术网