欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

EffectiveC++笔记 第5章

程序员文章站 2022-04-04 16:28:33
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。 Chapter 5 实现 Implementations 适当提出属于你的class定义以及各种functions声明相当花费心思。一旦正确完成它们,相应的实现大多直截了当。尽管如此,还是要 ......

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

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可继续持有原背景或赋给默认背景图案。

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

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