Effective C++ 读书笔记(十)

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

假设你的程序涉及到矩形,每个矩形又左上角和右下角表示。为了让矩形类尽可能小,我们可能需要一个辅助的struct来存放point类,再令Rectangle指向它。

class Point{
public:
    Point(int x,int y);
    ...
    void setX(int x);
    void setY(int Y);
};
struct RectData{
Point ulhc;
Point lrhc;
};
class Rectangle{
...
private:
    shared_ptr<RectData>pData;
};

Rectangle的使用者必须能计算Rectangle的范围,所以这个类提供upperLeft和lowerRight函数。Point式个用户自定义类型,根据条款二十尽量以引用返回,于是这些函数返回引用。

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

这样的设计尽管呢个通过编译,但是是错误的。

一方面这两个函数被声明为const,因为其只是用来获取相关坐标点的方法,而不是修改数据。另一方面两个函数却又都返回引用指向private内部数据,调用者可以根据这些引用来更改内部数据。

Point p1(0,0);
Point p2(100,100);
const Rectangle rec(p1,p2);
rec.upperLeft().setX(50);

这里内部数据被成功改变,但其实rec应该是不可变的。

这就带给我们教训,第一成员的封装性最多只等于返回其引用函数的访问级别。第二如果const成员传出一个引用,函数调用者可以修改函数内数据。

上面所说之事都是返回引用,如果返回的是指针或迭代器,情况相同。

通常我们认为,对象内部就是指它的成员变量,但其实不被公开使用的成员函数也是对象内部的一部分,因此也应该留意不要返回其handles。这也就是说不应该令成员函数返回一个指针指向访问级别较低的成员函数。道理类似。

我们上面所述的问题可以被轻松解决,只要对他们的返回类型加const即可

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

这尽管解决了问题,但还是返回了代表对象内部的handles,有可能在其他场合带来问题,他可能导致handles悬空:handles所指东西不存在。这种不存在对象最常见的来源旧式函数返回值。

class GUIObject{..};
const Rectangle boundingbox(const GUIObject& obj);
//客户可能这么使用
GUIObject* pgo;
const Point* pUpperLeft=&(boundingbox(*pgo).upperLeft());

对boundingbox的调用获得一个新的,暂时的 Rectangle对象。这个对象我们称之为temp,随后upperLeft作用于temp,返回一个引用指向temp内部的成分Point。于是pUpperLeft指向那个Point对象。截至目前还好。但再这个语句结束后,temp将被销毁,而那也间接导致temp内的Points析构,最后pUpperLeft指向一个不存在对象,变得悬吊。

这就是为什么返回handle代表对象内部成分总是危险的原因。无论其是引用还是指针,是否为const,只要有handles被传出去了,那么就会有handles比所指对象更长寿的风险。

这并不意味着绝不可以返回handles,有时候你必须那么做。如operator[]。但这样的函数毕竟是例外,不是常态。

总结:

  • 避免返回handles指向对象内部。遵守这个条约可以增加封装性,帮助const成员函数行为像个const,并将悬吊的可能性放到最低

条款二十九:为异常安全性而努力是值得的

假设有个类用来表现夹带背景图案的GUI菜单。这个类希望用于多线程环境,所以有个互斥器作为并发控制使用。

class PrettyMenu{
public:
    ...
    void changeBackground(istream& imgSrc);
    ...
private:
  Mutex mutex;
   Image * bgImage;
    int imageChange;
};
void PrettyMenu::changeBackground(istream& imgSrc);
{
    lock(&mutex);
    delete bgImg;
    ++imageChange;
    bgImage=new Image(imagesrc);
    unlock(&mutex);
}

从异常安全性来看,这个函数很糟,。异常安全性有两个条件,而这个函数没有满足任何一个。

当异常被抛出时,带有异常安全性的函数会

  • 不泄露任何资源。上述代码一旦new导致异常,那么对unlock的调用就绝不会执行,互斥器就永远被把持住了。
  • 不允许数据败坏。如果new抛出异常,bgimge就会指向一个被删除的对象,imagechange也被累加,但没有新的图像被成功安装。

解决资源泄露的问题很容易,因为条款十三提出以资源管理对象,并且条款十四也导入了Lock类作为确保互斥器被及时释放的方法。

void PrettyMenu::changeBackground(istream& imgSrc);
{
    Lock m1(&mutex); //条款十四
    delete bgImg;
    ++imageChange;
    bgImage=new Image(imagesrc);
    
}

资源泄露问题已经被解决,现在我们专注于数据的败坏。

异常安全函数提供以下三个保证之一

  • 基本承诺。如果异常被抛出,程序内任何事物仍保持在有效状态下。没有任何对象或数据结构会因此败坏,所有对象都处于一种内部前后一致的状态。
  • 强烈保证。如果异常被抛出,程序状态不变。调用这样的函数需要有这样的认识:如果函数成功,就是完全成功,如果函数失败,程序会回到调用函数之前的状态。
  • 不抛掷(nothrow)保证。承诺绝不抛出异常,因为他们总是能够完成他们原先承诺的功能。

我们假设,函数带着异常明细者必为nothrow函数,似乎合情合理,其实不尽然。

int dosomething()throw();

这并不是说dosomething绝不会抛出异常,而是如果抛出异常,将是严重错误,会有意想不到的函数被调用。实际上dosomething也没有提供任何异常保证。函数的声明并不能确保它时正确的、可移植的、高效的,也不能提供任何异常安全性保证。所有性质都由函数的实现决定,不关乎声明。

异常安全代码必须提供以上三个保证之一,如果不这样做,就不具备异常安全性。我们的抉择是改为我们所写的函数提供哪种保证。除非面对不具异常安全性的传统代码,否则应该提供异常安全保证。

一般而言你应该会想提供最强烈保证。尽管从异常安全角度讲nothrow函数很棒,但很难实现。任何动态内存的东西(如STL)如果无法找到足够内存以满足需求,通常就会抛出一个bad_alloc异常。所以对大多数函数而言,抉择往往在基本保证和强烈保证之间。

对于changeBackground函数而言,提供强烈保证并不困难。首先改变PrettyMenu的bgImage成员变量的类型,将其改为智能指针,它可以防止资源泄露。第二,重新排列changeBackground内的语句次序,使得在更换图像后再累加imageChange。这是个好策略,不要为了表示某件事情发生而改变对象状态,除非其真的发生了。

class PrettyMenu{
    ...
    shared_ptr<Image>bgImage;
};
void PrettyMenu::changeBackground(istream& imgSrc);
{
    Lock m1(&mutex); //条款十四
    bgImg.reset(new Image(imagesrc));
    ++imageChange;
   
}

这里由智能指针内部进行delete,且reset函数只有在其参数被成功执行后才会被调用。delete在reset内部被调用,如果未进入reset函数就不会执行delete。

这两个改变足以让changeBackground函数提供强烈的异常安全保证。美中不足的是参数imgsrc,如果其构造函数抛出异常,输入流的读取信号已被移走,而这样的搬移对程序其余部分是一种可见的状态改变。所以在changeBackground解决此问题之前只提供基本的异常安全保证。

假装changeBackground提供了强烈的异常保证。有个一般化的设计策略很典型的会导致强烈保证,这个策略被称为copy ans swap。原则很简单:为你打算修改的对象做出一份副本,然后在那个副本身上做改变。若有任何修改动作抛出异常,源对象仍保持未改变状态。等所有改变成功后,再将修改过的那个副本和源对象在一个不抛出异常的操作中置换。

实现上通常是将所有隶属对象数据从源对象放入另一个对象中,然后赋予源对象一个指针,指向副本。这种手法称为pimpl idiom,条款31详细介绍。

struct PMImpl{
 shared_ptr<Image>bgImage;
int Imagechange;
};
class PrettyMenu{
    private:
    Mutex mutex;
    shared_ptr<PMImpl>PImpl;
};
void PrettyMenu::changeBackground(istream& imgSrc);
{
    using std::swap;//条款二十五
    Lock m1(&mutex); //条款十四
    shared_ptr<PMImpl>Pnew(new PMImpl(*pImpl));
    pnew->bgImg.reset(new Image(imagesrc));
   ++pnew->imagechange;
    swap(pImpl,pnew);
 
   
}

此例中PMImp是struct而不是class,因为pImpl已经是private。

copy and swap策略是对对象状态做出全有或全无改变的一个很好方法,但一般它不保证整个函数有强烈的异常安全性。为了解释原因,让我们考虑changeBackground的抽象概念somefunc。它使用copy and swap策略,但函数内还包括另外两个函数的调用。

void somefunc()
{
    ...//做副本
    f1();
    f2();
    ...//改回修改后状态
};

如果f1和f2的异常安全性比强烈保证低,就很难让somefunc成为强烈异常安全。假设f1只提供基本保证,那么为了让somefunc具有强烈保证,我们必须写代码获得调用f1之前的整个函数状态、捕捉f1的所有可能异常、然后回复原状态。

即使f1和f2具有强烈保证,情况也不会好转。毕竟如果f1圆满结束,程序状态可能已经有所改变,因此f2抛出异常程序状态和somefunc未调用前并不相同,甚至当f2没有改变任何东西时也是如此。

问题出在连带影响。如果函数只操作局部性状态,便相对容易提供强烈保证。但当函数对非局部数据有连带影响时,提供强烈保证就困难得多。比如f1带来的影响是某个数据库被改动了,那么就很难让somefunc具备强烈安全性。

这些说法可能会让你放弃为函数提供强烈保证,即使你想,也要考虑到效率。copy and swap的关键在于修改对象数据的副本,然后再一个不抛异常的函数中置换,副本会消耗空间和时间。

在强烈保证可被实现时你的确应该提供它,但强烈保证并非在任何时刻都显得实际。

当强烈保证不可行时,你就必须提供基本保证。(可能实现强烈保证需要耗费巨大成本,基本保证是一个通情达理的选择)

若你写的代码完全不提供异常安全保证,别人可以合理假设你这方面有缺失,知道你证明自己的清白。你应当写出异常安全码。不过你也有可能有令人信服的理由,如somefunc中,f2完全不提供任何异常安全保证,连基本保证都没有,那么f2一旦抛出异常,程序可能会在f2内泄露资源。这意味着f2可能败坏数据结构。somefunc无法补偿那些问题。如果somefunc调用的函数不提供任何异常保障,somefunc也不可能提供任何保证。

一个软件系统要么具备异常安全性,要不就全然否定,没有所谓局部异常安全保证。系统内有一个函数不具备异常安全性,那么整个系统就不具备。(部分旧的c++代码不具备异常安全0

当你攥写代码时,请仔细想想如何令其具备异常安全性。

首先,以对象管理资源可防止资源泄露。然后挑选三个异常保证中的一个实施于所有你写的函数身上。应该挑选可实施的最强等级。

总结:

  • 异常安全函数即使发生异常也不会泄露资源或者允许数据结构败坏。保证分为三种:基本、强烈、不抛异常
  • 强烈保证往往能够通过copy and swap实现出来,但强烈保证并非对所有函数都可实现
  • 函数提供的异常安全保证通常最高只等于其所调用的各个函数的异常安全性的最弱者。

条款三十:透彻了解inlining的里里外外

Inline函数有诸多优点,看起来像函数、动作像函数、比宏好得多,可以调用他们不需蒙受函数调用的额外开销。它还有一些想象之外的好处。编译器最优化机制通常被设计用来浓缩那些不含函数调用的代码,所以当inline某个函数,或许编译器就能对其执行语境相关最优化。

然而天下没有白吃的午餐,inline函数也不例外。inline函数背后的理念是将对此函数每一次的调用都用函数本体置换,这样可能增加你的目标码大小。一台内存有限的机器上,过度热衷于inline会造成程序体积太大,会带来一系列效率的损失。

换个角度而言、若inline函数本体很小,编译器针对本体所产出的码可能比函数调用产出的更小。

记住,inline只是对编译器的申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。

隐喻方式是将函数定义于class定义式内

class Person
{
public:
    int age()const{return theage;}//隐喻的inline申请
private:
    int theage;
};

这样的函数通常是成员函数,但条款46说友元函数也可以定义于类内,他们也是被隐喻声明为inline。

明确的inline函数做法则是再起定义式前加inline关键字。如标准的max template往往这样实现出来

template<typename T>
inline const T& max(const T&a,const T&b)
{ return a<b?b:a;}

我们发现inline函数和templates两者通常都被定义于头文件内,这使得一些程序员认为函数模板一定必须是inline。这个结论不但无效,而且有害。

inline函数通常一定被置于头文件内,因为大多数构建环境在编译过程中进行inline,为了将函数调用替换为被调用函数的本体,编译器必须知道那个函数长什么样子。某些编译器在链接、运行时完成inline,但那只是个例。大部分是编译行为。

template通常也被置于头文件内,因为其一旦被调用,编译器为了将它具体化,需要知道它长什么样子。

template的具体化和inline无关。如果你在写一个template,并且你认为所有根据此template具体出来的函数都应该inline,那么请将这个template声明为inline,这就是上述max函数的做法。否则,则应该避免声明为inline,inline需要成本,盲目使用会引发代码膨胀等问题。

我们先忘记inline只是个申请这个结论。大部分编译器拒绝将太过复杂的函数inline,而对所有虚函数的调用也都会使inline落空。因为虚函数意味着等待运行期才决定调用那个函数,inline意味着执行函数前,先将调用工作置换为本体,相互矛盾。

这些叙述整合的意思是:一个表面看似inline的函数是否真的inline,取决于你的构建环境,主要取决于编译器。大多数编译器提供了一个诊断级别:如果他们无法将你要求的函数inline,会给你一个警告信息。

有时候虽然编译器有意愿inline某个函数,还是可能为该函数生成一个函数本体。比如如果程序要取某个inline函数地址,编译器通常必须为此函数生成一个outline函数本体。毕竟编译器哪有能力提出一个指针指向不存在的函数呢?与此并提的是,编译器通常不对通过函数指针而进行调用实施inline,这意味着inline函数的调用有可能被inline,也有可能不inline,取决于调用的实施方式。

inline void f(){...};
void (*pf)()=f;
f();   //inline
pf();//或许不被inline,因为通过函数指针达成

即使你从未使用函数指针,inline也有可能未被成功实施,因为程序员并非唯一要求函数指针的人。有时候编译器会生成构造函数和析构函数的outline副本,如此一来他们就可以获得指针指向那些函数,在array内部元素的构造和析构过程使用。

构造函数和析构函数往往是inline糟糕的候选人。

class Base{
public:
...
private:
 string bm1,bm2;
};
class Derived:public Base{
public:
 Derived(){}
    ...
private:
    string dm1,dm2,dm3;
};

看起来Derived的构造函数不含任何代码,是inline的绝佳候选人,其实不然。

c++对于对象创建和销毁发生了什么事做了各式各样的保证。使用new,动态创建的对象被其构造函数自动初始化;使用delete,对应析构函数被调用。当你创建一个对象,基类及每个成员变量都会被构造,销毁时亦然。这些事如何发生c++并没有描述清楚,但是编译器实现者的职责。但我们应该清楚,一定有某些代码让这些事发生,而那些代码肯定存在某个地方。有时候就放在构造函数和析构函数里。所以上文中那个看起来为空的构造函数,相当于

class Derived:public Base{
public:
 Derived(){}
    ...
private:
    string dm1,dm2,dm3;
};
  Derived::Derived(){
Base::Base();
try{dm1.string::string();}
catch(...){
    Base::~Base();
throw;
}
try{dm2.string::string();}
catch(...){
    dm1.string::~string();
    Base::~Base();
throw;
}
try{dm3.string::string();}
catch(...){
dm1.string::~string();
dm2.string::~string();
    Base::~Base();
throw;
}

这段代码不能代表编译器真正制造出的代码,但可能更复杂。尽管如此,这已经能够确定Derived的空白构造函数必须提供的行为,其会调用基类和成员函数的构造函数,而那些调用会影响编译器是否对空白函数inline。

相同的道理也适用于Base类。如果其被inline,所有替换base构造函数调用而插入的代码也会被插入到Derived构造函数中。类似的思考也适用于析构函数。

程序设计这必须评估inline带来的冲击:inline函数无法随着程序库的升级而升级。意思是如果f是一个inline函数,客户将f本体编进程序中,若f被改变,所有用到f的程序必须重新编译。然而若f是非inline函数,一旦其有任何修改,客户端只需重新连接,远比重新编译负担少。如果程序采取动态链接,升级函数甚至不知不觉就可以被吸纳。

对程序开发而言,要将上述考虑牢记在心。若单纯从实用角度出发,有一个事实比其他元素更重要:大部分调试器面对inline束手无策。毕竟你如何在一个不存在的函数内设立断点呢?

这使得我们再决定那些函数该声明为inline而那些不该时,掌握一个合乎逻辑的策略。一开始先不要让任何函数成为inline,或至少将inline实施范围局限在那些一定成为inline或十分平淡无奇的函数身上。慎重使用inline便是对日后使用调试器带来帮助。不要忘记80-20经验法则:平均而言一个程序往往将80%的执行时间花费在20%的代码头上。这个法则提醒你,作为一个软件开发者,你的目标是找出这有可能增进程序整体效率的20%代码,然后将它inline或尽可能为其瘦身。

总结:

  • 将大多数inline限制在小型、被频繁使用的函数身上。这可使如后的调用过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序速度提升机会最大化。
  • 不要只因为函数模板出现在头文件,就将其声明为inline。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值