C++基础知识归纳(3) -进阶篇《Effective C++必懂条款》

析构函数需要设置为虚函数(virtual)的原因

带多态性性质的base class应该声明一个virtual虚构函数。否则使 指针指向派生类时使用delete操作只会删除基类成员而不会删除派生对象中的成员从而造成内存泄露。如果类不是作为基类使用或者不是为了具备多态性,就不应该声明virtual析构函数。

  1. 每个析构函数只会清理自己的成员(成员函数前没有virtual)。
  2. 可能是基类的指针指向派生类的对象,当析构一个指向派生类的成员的基类指针,这时程序不知道这么办,可能会造成内存的泄露,因此此时基类的析构函数要定义为虚函数;
    基类指针可以指向派生类的对象(多态),如果删除该指针delete[]p,就会调用该指针指向的派生类的析构函数,而派生类的对象又会自动调基类的成员函数,这样就会把派生类的对象释放,如果基类的析构函数没有定义成虚函数,则编译器实现的静态绑定,在删除基类的指针,只会释放基类的析构函数而不会释放派生类的成员函数,此时会导致释放内存不完全,就会导致内存泄露的问题。
  • 释放放pd的过程是:只是释放了基类的资源,而没有调用派生类的析构函数;这样的删除只能删除基类对象,而不能删除派生类的对象,造成内存泄露;
  • 在公有继承中,基类指针对派生类的对象的操作,只能影响哪些继承的成员,如果想要基类对非继承进行操作,则要把这个基类函数定义为虚函数;
  • 当基类的析构函数被定义成为虚函数,编译器就不会实现静态绑定,这样当释放pd时,会先释放派生类的析构函数,再释放基类的析构函数。

析构函数不要吐出异常

  1. 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们(不传播)或结束程序。
  2. 如果需要对某个操作函数抛出的异常做出反应,那么class应该提供一个普通函数而非在析构函数中执行该操作。

绝对不要在类的构造和析构函数中调用虚函数

  1. 从语法上讲,调用完全没有问题。
  2. 但是从效果上看,往往不能达到需要的目的。
    Effective 的解释是:
  • 派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。
    同样,进入基类析构函数时,对象也是基类类型。
    所以,虚函数始终仅仅调用基类的虚函数(如果是基类调用虚函数),不能达到多态的效果,所以放在构造函数中是没有意义的,而且往往不能达到本来想要的效果。
  • C++构造函数和析构函数调用虚函数时都不会使用动态联编

总结:构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本。–所以没有意义

在operator =中处理自我赋值

在有些情况下需先删除自身所指向资源,再根据s指向资源创建新资源,此时自我复制会出现错误)

  • 解决自我赋值只要一句话:if(this == &s) return *this; // 解决自我赋值的一句话

  • 不能写成if(*this == s) return *this; // 注意条件判断的不同,这样写有问题!(因为判断是地址是否重合,而不是指针指向的内容是否相同。)

两种解决思路:

  1. 保存好旧的,再试着申请新的,若申请有问题,旧的还能保存。
 SampleClass& operator= (const SampleClass& s)
  {
           if(this == &s) return *this; //可以删掉
           a = s.a;
           b = s.b;
           float* tmp = p; // 先保存了旧的指针(深拷贝)
           p = new float(*s.p); // 再申请新的空间,如果申请失败,p仍然指向原有的地址空间
           delete tmp; // 能走到这里,说明申请空间是成功的,这时可以删掉旧的内容了
           return *this;
}
  1. 先用临时的指针申请新的空间并填充内容,没有问题后,再释放到本地指针所指向的空间,最后用本地指针指向这个临时指针。
SampleClass& operator= (const SampleClass& s){
          if(this == &s) return *this; //可以删掉
           a = s.a;
           b = s.b;
          float* tmp = new float(*s.p); // 先使用临时指针申请空间并填充内容
           delete p; // 若能走到这一步,说明申请空间成功,就可以释放掉本地指针所指向的空间
           p = tmp; // 将本地指针指向临时指针
           return *this;
 }

上述两种方法都是可行,但还要注意拷贝构造函数里面的代码与这段代码的重复性,试想一下,如果此时对类增加一个私有的指针变量,这里面的代码,还有拷贝构造函数里面类似的代码,都需要更新,有没有可以一劳永逸的办法?

SampleClass& operator= (const SampleClass& s)
{
         SampleClass tmp(s);
         swap(*this, tmp);
         return *this;
}
  1. 一种进一步优化的方案如下:
1 SampleClass& operator= (const SampleClass s)
2 {
3          swap(*this, s);
4          return *this;
5 }

这里去掉了形参的引用,将申请临时变量的任务放在形参上了,可以达到优化代码的作用。

以对象管理资源(RAII)

class Node {};
Node* CreateNode()
{
    
}
void Solve()
{
    Node *p=CreateNode();  //调用CreateNode函数
    ...
    delete p;         //释放资源
}

Solve函数可能因为continue、break等跳过不执行而导致内存泄露。
为使申请的资源能够自动释放,使用智能指针:

void Solve()
{
    std::auto_prt<Node>p(CreateNode());
    ...                    ///经由auto_ptr析构函数自动删除p对象
}

它还有另外一个性质:即通过copy构造函数或者copy assignment函数对他们进行复制操作时,它们会变成null,而复制所得的指针将拥有对资源的唯一支配权。

void Solve()
{
    std::auto_prt<Node>p1(CreateNode());
    std::auto_prt<Node>p2(p1);  //p2指向对象,p1为null
    p1=p2;                      //p1指向对象,p2为null
}

通过上面的例子藐视auto_ptr智能指针的弊端已经显露出来了,即无法行使正常的复制行为。

tr1::shared_ptr(引用计数智能指针):它不仅拥有auto_ptr智能指针的功能,最重要的是它还能进行复制行为。

void Solve()
{
   std::tr1::shared_ptr<Node>p1(CreateNode());
   std::tr1::shared_ptr<Node>p2(p1);  //p1和p2指向同一对象
   p1=p2;           //p1和p2指向同一对象
   ...             //p1,p2同时被销毁,它们所指的对象也被自动销毁
}
1. 为防止资源泄露,请使用RAII对象。它们在构造函数过程中获得资源,并在析构函数过程中释放资源。
2. 两个常用的RAII classes分别是auto_ptr,tr1::shared_ptr,后者更佳。

shared_ptr在最新的c++11中,已经被列入了标准指针,而auto_ptr则出局了。
shared_ptr(#include )采用RAII技术,是防止内存泄露的神器。(RAII,也称为“资源获取就是初始化”,是c++等编程语言常用的管理资源、避免内存泄露的方法。它保证在任何情况下,使用对象时先构造对象,最后析构对象。)

在资源管理类中提供对原始资源的访问方式

1.显示访问:

所谓显示访问就是在管理类的内部提供某个函数,使得外界可以得到资源的指针。通过这个函数被命名为get()函数,当然为了方便,我们也可以重载*,->运算符。

假设有一类资源FontHandle 字体处理类:

FontHandle getHandle();得到字体资源
void releaseFont(FontHandle fh);释放字体资源
class Font
{
public:
Font(FontHandle fh):f(fh)
{
}
~Font()
{
releaseFont(f);
}
FontHandle get() const
{
    Return f;
}
private:
FontHandle f;
};

通过get()函数访问原始资源。

2. 隐式转换

假设资源管理类已经提供了显示访问的API,那么用户每次访问底层资源都需要显示地调用get()函数,这样既有好处也有不足。好处在于这种转换都是用户知晓的,由用户控制的,不会发生一些用户不愿意转换却转换的事情。不足在于,如果这类显示访问太于频繁将很影响管理类的便利性。
于是隐式转换就出现了,隐式转换提供一种自动将资源管理对象转换为原始资源指针的功能。这主要是通过重载类型转换运算符实现的。

class Font
{
public:
Font(FontHandle fh):f(fh)
{
}
~Font()
{
releaseFont(f);
}
FontHandle get() const
{
    Return f;
}
operator FontHandle() const    //重载类型转换运算符
{
    Return f;
}
private:
FontHandle f;
};

以独立语句将newed对象置入智能指针

对以下代码:

processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());

编译器创建代码做以下三件事:

  1. 调用priority()
  2. 执行new Widget
  3. 调用shared_ptr构造函数

C++不同于很多语言,C++完成上面的事的顺序可能是123,213, 231。
如果顺序是213,那么当priority发生异常时,new Widget产生的指针就会丢失,引发资源泄漏。
避免的方法很简单,使用分离语句:

std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

尽量以pass-by-reference-to-const来代替pass-by-value

  • 本质传的是地址,所以不存在对象的切割(地址类型Person&,可以理解为引用类型或者指针类型,存放的都是对象的地址,对于32位机而言,是4字节的整数,Person型的地址只是告诉编译器应该把这段地址的内容解释成什么)。Person&只是告诉编译器它保存的地址对应的内容是一个Person类型的,它会优先把这个内容往Person上去套,但如果里面有虚函数,即使用了virtual关键字,那么编译的时候就会往类中安插一个虚指针,这个虚指针的指向将在运行时决定,这就是多态的机制了,它会调用实际传入的那个对象的虚函数,而不是基类对象的虚函数。

  • 如果没有接触过多态,上面说的可能就会有难度,其实也可以简单的理解成,如果不加引用或指针,那么形参就会复制实参,但形参是基类的,它没有实参多出来的那部分,所以它就不能复制了,只能丢弃了;但如果加了引用或指针,那么无论是什么类型的,都是保存实参的地址,地址是个好东西啊,它不会发生切割,不会丢弃。

总结一下pass-by-reference-to-const的优点:
一是可以节省资源复制的时间和空间;
二是可以避免切割,触发多态,在运行时决定调用谁的虚函数。

那么是不是pass-by-reference-to-const一定好呢?
答案显示是否定的。一是任何事物都不是绝对的,二是C++默认是pass-by-value,自然有它的道理。

可以这样解释,因为pass-by-reference传的是地址,在32位机上,就是4字节,但如果参数是一个字节的char或者bool,那么这时候用char&或bool&来传引用就不划来了。一般地,当参数是基本类型时,还是用pass-by-value的效率更高,这个忠告也适用于STL的迭代器和函数对象。
对于复杂的类型,还是建议pass-by-reference-to-const,即使这个类很小。但事实上,一个类看似很小,比如只有一个指针,但这个指针也许指向的是一个庞然大物,在深拷贝的时候,所花费的代价还是很可观的。另一方面,谁也不能保证类的内容总是一成不变的,很有可能需要扩充,这样原本看似很小的类,过一段时间就成了“胖子”了。

最后总结一下:

  1. 尽量以pass-by-reference-to-const来代替pass-by-value,前者通常比较高效,并可以避免切割
  2. 以上规则并不适用于内置类型、STL迭代器和函数对象,对它们而言,pass-by-value是更好的选择

当必须返回对象时,别妄想返回其reference

  1. 如果返回的是函数内直接声明的对象,其位于栈上,那函数结束后其会被回收,返回的引用就指空;
  2. 如果返回的是在函数内new出来的对象,那么该对象的内存释放很难处理(不能在函数内释放,函数外释放又很麻烦和复杂,当然,如果把new换成auto_ptr或者是shared_ptr,这种资源泄露的问题就可以避免。);
  3. 如果返回的是静态变量的引用,那么函数每次执行完返回的都是同一个值;
    我们还有static对象可以用,static对象位于全局静态区,它的生命周期与这个程序的生命周期是相同的,所以不用担心它会像栈对象那样很快消失掉,也不用担心它会像堆对象那样有资源泄露的危险。可以像这样写:
friend const Rational& operator* (const Rational& r1, const Rational& r2)
{
    static Rational temp;
    temp.numerator = r1.numerator * r2.numerator;
   temp.denominator = r1.denominator * r2.denominator;
    return temp;
}

这样写编译器同样不会报错,但考虑一下这样的式子:

1 Rational r1, r2, r3;
2 if(r1 * r2 == r1 * r3){}
  • if条件恒为真,这就是静态对象做的!因为所有对象共享这个静态对象,在执行r1 x r2时,temp的值为t1,但执行r1 x r3之后,temp的值统一都变成t2了。它在类中只有一份,明白这个原因后就不难理解了。(被多个函数调用返回值是统一的);
  • 既然一个static对象不行,那弄一个static数组?把r1 x r2的值放在static数组的一个元素里,而把r1 x r3放在static数组的另一个元素里?仔细想想就知道这个想法是多么的天马行空。
    一个必须返回新对象的正确写法是去掉引用,就这么简单!

最后总结一下:

绝对不要返回pointer或reference指向一个local stack对象,指向一个heap-allocated对象也不是好方法,更不能指向一个local static对象(数组),该让编译器复制对象的时候,就让它去复制!

宁以non-member、non-friend替换member函数

本条款强调封装性优先于类的内聚逻辑,这是因为“愈多东西被封装,愈少人可以看到它,
而愈少人看到它,我们就有愈大的弹性去改变它,因为我们的改变仅仅影响看到改变的那些人或事物”。
采用namespace可以对内聚性进行良好的折中。
一句话:“宁可拿non-member non-friend函数替换member函数,这样可以增加封装性、
包裹弹性和机能扩充性”。

若所有参数皆需类型转换,请为此采用non-member函数

成员函数实现不能支持含隐式转换的交换率(this指针指向的对象无法隐式转换),而友元函数实现却可以,故我们认为友元函数实现要优于成员函数实现(程序不支持乘法交换率是难以接受的)。
解决了这个问题之后,再来思考一下,能否对封装性进行改进,因为条款二十三说了,宁以non-member,non-friend函数去替换member函数,其实对友元函数形式稍加修改,去掉friend,改用public成员变量来访问函数内的私有成员,就可以实现好的封装,像这样:

const Rational operator* (const Rational& r1, const Rational& r2)
{
     return Rational(r1.GetNumerator() * r2.GetNumerator(),
     r1.GetDenominator() * r2.GetDenominator());
}

在这个函数里面,是不能直接访问到类的私有成员的,因而保证了好的封装性(直接获取改为通过函数获取)。

最后总结一下:

如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member,且为了封装,最好是个non-friend。

尽量少做转型动作

C++风格的转型操作分成四类:

const_cast<T>(expression)
static_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)

effective推荐使用的是前三种,最后一种是把一个指针转成整数或者把整数看成指针,对平台依赖性很强,不建议使用。

1.const_cast很简单,就是去掉常量属性
int main()
{
    int a= 3;
    const int* ca = &a;
    int *pb = const_cast<int*> (ca);
    *pb = 5;
    cout << a << endl; // a的输出结果是5
}

但这种常量性的转换只是针对于指针或引用(包括this指针),是不能针对于普通变量的,比如下面的做法就是错误的:

int main()
{
    const int a = 3;
    int b = const_cast<int>(a);  //编译出错,只能使用static_cast或者C风格转换
}

对于const_cast,既可以将const->非const,也可以将非const->const,只要记住两点,其一是这个操作只对指针或引用有效;其二,这个操作并不改变被转换对象的常属性,对于:

int a= 3;
const int* ca = &a;
int *pb = const_cast<int*> (ca);

ca仍是指向常量的指针,对它的操作,比如*ca = 5,是会报编译错的。

2.static_cast

static_cast是最为常用C++转型了,我们常见的int->double亦或是float->int等等,都是用static_cast来进行转换的(包括const int -> int,以及int -> const int,只要不是常指针/引用->non常指针/引用)。

3.dynamic_cast

dynamic_cast是当想把基类指针转成派生类指针时用,这种转换可以保证安全性,当把两个不相干的类之间用dynamic_cast时,转换的结果将是空指针,同时若基类指针指向的对象只是基类本身时,对基类指针进行dynamic_cast向下转型到派生类,会得到空指针,防止进一步的操作。

下面来讲一下,为什么effective中要我们尽量少做转型动作。

  • 不清楚原理的转型会带来严重的bug.
  • 有的转型操作也是比较废的,比如dynamic_cast,这个转型会对类名称进行strcmp,以判断向下转型是否合理,如果继承深度比较大,那么每一次的dynamic_cast将会进行多次strcmp,这将严重影响程序的执行效率。解决方法就是如果可以话,直接使用指向子类的指针,真的想用父类的指针(比如工厂设计模式等),那就考虑多态吧,在父类相应的函数前面加virtual,然后子类进行覆盖即可。
总结
  1. 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。
  2. 如果转型是必要的,试着将它隐藏于某个函数背后,客户可以随后调用这个函数,而不需要将转型放在他们自己的代码里。
  3. 宁可使用C++风格的新式转型,少用C风格转型,因为前者很容易辨识出来,而且也比较有着分门别类的职掌。

避免返回handlers指向对象内部成分

如果返回的引用生命周期比对象本身要长时,引用就会悬空,它会指向一个不存在的string。下面看一下“返回的引用生命周期比对象本身要长”的情况,这种情 况还是很容易举出例子的,比如:

const string& fun()
{
     return Student().GetName();
}
int main()
{
    string name = fun(); //name指向一个不存的对象的成员变量
}

这时候即使name读取不报错,也是一个巨大的隐患,因为它已经是虚吊(dangling)的了。

这就是为什么函数如果“返回一个handle代表对象内部成分”总是危险的原因,不在于返回值是不是const,而是在于如果handle(指针或引用)传出去了,就会暴露在“handle比其所指对象更长寿”的风险下。

但有些情况还是需要返回handle的,比如string或者vector里面的operator[],就是返回的引用,因为需要对这里面的元素进行操作。

总结一下:

避免返回handles(包括reference、指针、迭代器)指向对象内部,遵守这个条款可增加封装性,并将发生dangling handles的可能性降至最低。如果有必要必须要返回handles,在编写代码时就一定要注意对象和传出handle的生命周期。

为“异常安全”而努力是值得的

上升到理论的高度,异常安全性要做到:

  1. 不泄漏任何资源
  2. 不允许数据败坏

带异常安全性的函数会提供三个保证之一:

  1. 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或者数据结构会因此被破坏。比如上例中本次更换背景图失败,不会导致相关的数据发生破坏。
  2. 强烈保证:在基本承诺的基础上,保证成功就是完全成功,失败也能回到之前的状态,不存在介于成功或失败之间的状态。
  3. 不抛出异常:承诺这个代码在任何情况下都不会抛出异常,但这只适用于简单的语句。

强烈保证有一种实现方法,那就是copy and swap。原则就是:在修改这个对象之前,先创建它的一个副本,然后对这个副本进行操作,如果操作发生异常,那么异常只发生在这个副本之上,并不会影响对象本身,如果操作没有发生异常,再在最后进行一次swap。

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    Lock m1(&mutex);
    弄一个临时的tempBgImage
    对tempBgImage进行操作
    swap(tempBgImage, bgImage);
    ++ imageChanges;
}

copy-and-swap策略关键在于“修改对象数据的副本,然后在一个不抛异常的函数中将修改后的数据和原件置换”。它确实提供了强异常安全保障,但代价是时间和空间,因为必须为每一个即将被改动的对象造出副本。另外,这种强异常安全保障,也会在下面的情况下遇到麻烦:

void someFunc()
{
    f1();
    f2();
}

f1()和f2()都是强异常安全的,但万一f1()没有抛异常,但f2()抛了异常呢?是的,数据会回到f2()执行之前的状态,但程序员可能想要的是数据回复到f1()执行之前。要解决这个问题就需要将f1与f2内容进行融合,确定都没有问题了,才进行一次大的swap,这样的代价都是需要改变函数的结构,破坏了函数的模块性。如果不想这么做,只能放弃这个copy-and-swap方法,将强异常安全保障回退成基本保障。

类似于木桶效应,代码是强异常安全的,还是基本异常安全的,还是没有异常安全,取决于最低层次的那个模块。换言之,哪怕只有一个地方没有考虑到异常安全,整个代码都不是异常安全的。

总结一下:

  1. 异常安全函数是指即使发生异常也不会泄漏资源或者允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
  2. 强烈保证往往可以通过copy-and-swap实现出来,但“强烈保证”并非对所有函数都可以实现或具备现实意义。
  3. 异常安全保证通常最高只等于其所调用之各个函数的异常安全保证中的最弱者。

将文件间的编译依存关系降至最低

如果它的头文件变了,那么所有这个类的对象所在的文件都要重编,但如果它的实现文件(cpp文件)变了,而头文件没有变(对外的接口不变),那么所有这个类的对象所在的文件都不会因之而重编。

编译器重编的条件是发现一个变量的类型或者大小跟之前的不一样了。

用Interface Classes来降低编译的依赖:Interface Classes则是利用继承关系和多态的特性,在父类里面只包含成员方法(成员函数),而没有成员变量;

总结一下,对于C++类而言,如果它的头文件变了,那么所有这个类的对象所在的文件都要重编,但如果它的实现文件(cpp文件)变了,而头文件没有变(对外的接口不变),那么所有这个类的对象所在的文件都不会因之而重编;
避免重编的诀窍就是保持头文件(接口)不变化,而保持接口不变化的诀窍就是不在里面声明编译器需要知道大小的变量,Handler Classes的处理就是把变量换成变量的地址(指针),头文件只有class xxx的声明,而在cpp里面才包含xxx的头文件。

因此,避免大量依赖性编译的解决方案就是:在头文件中用class声明外来类,用指针或引用代替变量的声明;在cpp文件中包含外来类的头文件

Handler classes与Interface classes解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。减少编译依存性的关键在于保持.h文件不变化,具体地说,是保持被大量使用的类的.h文件不变化,这里谈到了两个方法:Handler classes与Interface classes。

  • Handler classes化类的成员变量为指针,在.h文件里面只包含class xxx的外来类声明,而不包含其头文件,在.cpp涉及到具体外来类的使用时,才包含xxx.h的头文件,这样最多只影响本身类的cpp重编,但因为.h文件没有变化,所以此类的对象存在的文件不必重编。
  • 当然,书上说的Handler classes更想让我们在类A的基础上另造一个中间类AImp(成员函数完全与类A一致),这个中间类的成员中里面放置了所有类A需要的外来类的对象,然后类的逻辑细节完全在Almp.cpp中实现,而在A.cpp里面只是去调用Almp.cpp的同名方法。A.h的成员变量只有Almp的指针,这看上去好像一个Handler,因此而得名。
  • Interface classes则是将细节放在子类中,父类只是包含虚方法和一个静态的Create函数声明,子类将虚方法实现,并实现Create接口。利用多态特性,在客户端只需要使用到Person的引用或者指针,就可以访问到子类的方法。由于父类的头文件里面不包含任何成员变量,所以不会导致重编(其实由于父类是虚基类,不能构造其对象,所以也不用担心由于父类头文件变化导致的重编问题)。

记住:

  1. 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式,基于此构想的两个手段是Handler classes和Interface classes(实现类和接口类)。
  2. 程序库头文件应该以“完全且仅有声明式”的形式存在,这种做法不论是否涉及templates都适用。

避免遮掩继承而来的名称

名称的遮掩可以分成变量的遮掩与函数的遮掩两类,本质都是名字的查找方式导致的,当编译器要去查找一个名字时,它一旦找到一个相符的名字,就不会再往下去找了,因此遮掩本质上是优先查找哪个名字的问题。

//例1:普通变量遮掩
int i = 3;
int main()
{
    int i = 4;
    cout << i << endl; // 输出4
}

这是一个局部变量遮掩全局变量的例子,编译器在查找名字时,优先查找的是局部变量名,找到了就不会再找,所以不会有warning,不会有error,只会是这个结果。

/2:成员变量遮掩
class Base
{
public:
    int x;
    Base(int _x):x(_x){}
};
class Derived: public Base
{
public:
    int x;
    Derived(int _x):Base(_x),x(_x + 1){}
};

int main()
{
    Derived d(3);
    cout << d.x << endl; //输出4
}

因为定义的是子类的对象,所以会优先查找子类独有的作用域,这里已经找到了x,所以不会再查找父类的作用域,因此输出的是4,如果子类里没有另行声明x成员变量,那么才会去查找父类的作用域。那么这种情况下如果想要访问父类的x,怎么办呢?
与变量遮掩类似,函数名的查找也是先从子类独有的作用域开始查找的,一旦找到,就不再继续找下去了。这里无论是普通函数,虚函数,还是纯虚函数,结果都是输出子类的函数调用。

1 //例4:重载函数的遮掩
class Base
 {
 public:
     void CommonFunction(){cout << "Base::CommonFunction()" << endl;}
     void virtual VirtualFunction(){cout << "Base::VirturalFunction()" << endl;}
     void virtual VirtualFunction(int x){cout << "Base::VirtualFunction() With Parms" << endl;}
     void virtual PureVirtualFunction() = 0;
 };

class Derived: public Base
{
public:
    void CommonFunction(){cout << "Derived::CommonFunction()" << endl;}
    void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}
    void virtual PureVirtualFunction(){cout << "Derived::PureVirtualFunction()" << endl;}
};

int main()
{
    Derived d;
    d.VirtualFunction(3); // ?
    return 0;
}

很多人都会认为输出的是Base::VirtualFunction() With Parms,实际上这段代码却是编译不过的。因为编译器在查找名字时,并没有“为重载而走的很远”,C++的确是支持重载的,编译器在发现函数重载时,会去寻找相同函数名中最为匹配的一个函数(从形参个数,形参类型两个方面考虑,与返回值没有关系),如果大家的匹配程度都差不多,那么编译器会报歧义的错。

但以上法则成立的条件是这些函数位于相同的作用域中,而这里是不同的域!编译器先查找子类独有的域,一旦发现了完全相同的函数名,它就已经不再往父类中找了!在核查函数参数时,发现了没有带整型形参,所以直接报编译错了。

如果去掉子类的VirualFunction(),那么才会找到父类的VirtualFunction(int)。

提醒一下,千万不要被前面的Virtual关键字所误导,你可以试一个普通函数,结果是一样的,只要子类中有同名函数,不管形参是什么,编译器都不会再往父类作用域里面找了。
好,如果现在你非要访问父类里面的方法,那也可以,书上给出了两种作法,一种是采用using声明,另一种是定义转交函数。

class Derived: public Base
{
using Base::VirtualFunction; // 第一级查找也要包括Base::VirtualFunction
void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}

}

或者:

void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}
void virtual VirtualFunction(int x){Base::VirtualFunction(x)};

最后总结一下:

  1. derived classses内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
  2. 为了让被遮掩的名称再见天日,可使用using声明式或转交函数(forwarding functions)。

区分接口继承和实现继承

只要能记住三句话即可,第一句话是:纯虚函数只继承接口;第二句话是:虚函数既继承接口,也提供了一份默认实现;第三句话是:普通函数既继承接口,也强制继承实现。这里假定讨论的成员函数都是public的。
最后总结一下:

  1. 接口继承和实现继承不同。在public继承之下,derived class总是继承base class的接口;
  2. pure virtual函数只具体指定接口继承;
  3. impure virtual函数具体指定接口继承和缺省实现继承;
  4. non-virutal函数具体指定接口继承以及强制性实现继承。

类的默认参数是静态绑定的,不支持多态

enum MyColor
 {
     RED,
     GREEN,
     BLUE,
 };
 
 class Shape
 {
 public:
     void virtual Draw(MyColor color = RED) const = 0;
 };
 
 class Rectangle: public Shape
 {
 public:
     void Draw(MyColor color = GREEN) const
     {
         cout << "default color = " << color << endl;
     }
 };
 
 class Triangle : public Shape
 {
 public:
     void Draw(MyColor color = BLUE) const
     {
         cout << "default color = " << color << endl;
     }
 };
 
 int main()
 {
     Shape *sr = new Rectangle();
     Shape *st = new Triangle();
     cout << "sr->Draw() = "; // ?
     sr->Draw();
     cout << "st->Draw() = "; // ?
     st->Draw();
     
     delete sr;
     delete st;
 }
函数的输出是什么?

要回答这个问题,需要回顾一下虚函数的知识,如果父类中存在有虚函数,那么编译器便会为之生成虚表与虚指针,在程序运行时,根据虚指针的指向,来决定调用哪个虚函数,这称之与动态绑定,与之相对的是静态绑定,静态绑定在编译期就决定了。

实现动态绑定的代价是比较大的,所以编译器在函数参数这部分,并没有采用动态绑定的方式,也就是说,默认的形参是静态绑定的(使用的是基类对象指针则绑定基类构造函数的默认值),它是编译期就决定下来了。

实际输出如下:
在这里插入图片描述
如果一定要为虚函数采用默认值,那么只要在父类中设定就可以了。可以借用条款35所说的NVI方法。

non-virtual interfaces(NVI),非虚拟接口,私有虚函数。

  • 令客户通过 public non-virtual 成员函数间接调用 private virtual 函数,是 Template mothod 设计模式的一种独特表现形式。这个 non-virtual 虚函数称为 virtual 函数的外覆器。(摘自《Effective C++》)
  • 基类的 public non-virtual 外覆器可以确保在真正的 virtual 函数操作之前设定好适当场景,并在调用结束后清理场景。“事前工作”可以包括锁定互斥器(lock a mutex),制造运转日志记录项(log entry),验证 class 约束条件,验证函数先决条件等;“事后工作”可以包括解除互斥器(unlock a mutex),制造运转日志等。(摘自《Effective C++》)

特点:

  1. 接口与实现分离,基类负责逻辑和事前事后工作,派生类专心负责数据操作。
  2. 基类更加稳定。倘若派生类实现部分改动,只需要重新编译派生类所在文件即可,不影响基类的逻辑部分。基类掌控接口所有权——如果不是 NVI,采用的是普通的虚函数覆盖机制,基类中加入的逻辑判断一旦改动,在所有派生类中的逻辑部分都要改动(因为派生类的 virtual 虚函数会覆盖基类的 virtual 虚函数),这些文件都要重新编译。一定程度上,NVI 的基类集中了对逻辑的掌控,而且不能被子类覆盖,这就是“ public non-virtual ” 接口,不是虚函数的原因。而 virtual 接口是 private 的原因——防止越过“逻辑部分”直接调用 virtual 函数的情况,纯数据操作如果不加入一些逻辑或者线程保护容易出现 bug。

总结:
绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值 都是静态绑定,而virtual函数——你唯一应该覆写的东西——却是动态绑定。

明智而审慎地使用多重继承

多重继承是一种比较复杂的继承关系,它意味着如果用户想要使用这个类,那么就要对它的父类也了如指掌,所以在项目中会带来可读性的问题,一般我们都会尽量选择用单继承去替代它。
使用多重继承过程容易碰到的问题就是名字冲突;

多重继承另一个容易碰到的问题就是虚继承,我记得这还是面试官的一道面试题。试想一下,有一个父类名为A,类B和类C都继承于A,类D又同时继承了B和C(多重继承),那么如果不做任何处理,C++的类继承图里会包含两份A。

为了保证不会出现两份父类,只要是public继承理论上都应该有virutal关键字,但virutal也是有代价的,访问virtual base class的成员变量要比访问non-virutal base class的成员变量速度要慢。所以作者的忠告是:

  1. 非必要不使用virtual classes继承,普通情况请使用non-virtual classes继承
  2. 如果必须使用virtual base classes,尽可能避免在其中放置数据。
    最后总结一下:
  3. 多重继承比单一继承更复杂。它可能导致新的歧义性,以及对virtual继承的需要。
  4. virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果virtual base classes不带任何数据,将是最具实用价值的情况。
  5. 多重继承的确有正当用途。其中一个情节涉及”public继承某个Interface class”和”private继承某个协助实现的class”的两两组合。

明智而审慎地使用private继承

private继承使用的地方实在不多,除非有一些奇葩的设计需求,书上说了一个例子:

 class TypeDefine
 {};
 
 class SimpleClass
 {
     int a;
     TypeDefine obj;
 };
 
 class SimpleDerivedClass : private TypeDefine
 {
     int a;
 };
 
 int main()
 {
     cout << sizeof(TypeDefine) << endl; // ?
     cout << sizeof(SimpleClass) << endl; // ?
     cout << sizeof(SimpleDerivedClass) << endl; // ?
 }

大家可以想一下“?”处的输出是什么。第一个是空类,空类就像是空气一样,仅仅是名字里面包含了“空”字,看起来是“空”的,但其实不是这样子的,空气里面混合了氧、氮、二氮化碳等气体,还有各种微生物,而对于空类,编译器会为之生成四个默认的函数:默认构造函数,默认拷贝构造,默认析构函数,默认赋值运算符。读者就会问了,编译器生成了默认的函数不假,但函数是不占空间的,为什么空类的sizeof算出的值是1?原来类的每一个对象都需要一个独一无二的内存地址,所以编译器会在空类对象中插入一个1字节变量,正是这个1字节的变量,才能够区分空类的不同对象。非空类因为已经有了成员变量,所以编译器可以利用这些成员变量来进行内存地址的区分,从而标识类的不同对象,这个时候是不需要插入一个1字节的变量的。所以第一个问号处输出的是1。

第二个问号输出的是5吗?int四字节再加到空类对象的四字节?理论上是这样,但编译器还会做一种内存对齐的操作,使得类对象的大小会是处理字长的整数倍,一般是4字节的整数倍,所以最后的结果其实是8。

第三个问号呢?前面讲的那么多,好像都与private无关,这个问题终于与它有关了。运行下看看,结果是4。为什么用复合模型时输出的结果是8,但private继承时却是4呢?这其实是编译器做了空白基类优化(EBO),原本是要为空白类对象插入1字节的,但因为子类中已经有了对象了,这样理论上就可以凭借这个对象来进行同一个类不同对象间的识别了,所以这时候编译器就不再插入字节了

这个结果就是用private继承的好处,是不是很奇葩呢~所以我说,在大部分情况下,都不会考虑private继承,因为它的含义be implemented in terms of 可以用复合来替换。

书上还提到了关于虚函数不想被子类的子类所覆写的问题,这时候不能用private限制虚函数,因为生成的虚指针是一直会被继承下去的,解决方法就是用复合,而且复合的类是一个临时类且复合对象标记为private,这样就只能限制在这个类本身去覆写了。具体的例子可以去看原书。

最后总结一下:

  1. Private继承意味着is implemented in terms of(根据),它通常比复合的级别低(即优先使用复合),但是当derived class需要访问protected base class的成员,或需要重新定义继承而来的virtual函数时,这么设计是合理的。
  2. 与复合不同,private继承可以造成empty base最优化。这对致力于“对象尺寸最小化”的程序库开发者而言,可能很重要。

虚函数、虚函数表,虚指针

在C++的标准规格说明书中说到,编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

虚继承的作用是减少了对基类的重复,代价是增加了虚表指针的负担(更多的虚表指针)。详细请查阅:虚指针、虚函数原理

下面总结一下(当基类有虚函数时):

  1. 每个类都有虚指针和虚表;
  2. 如果不是虚继承,那么子类将父类的虚指针继承下来,并指向自身的虚表(发生在对象构造时)。有多少个虚函数,虚表里面的项就会有多少。多重继承时,可能存在多个的基类虚表与虚指针;
  3. 如果是虚继承,那么子类会有两份虚指针,一份指向自己的虚表,另一份指向虚基表,多重继承时虚基表与虚基表指针有且只有一份。

隐式接口和编译期多态

显式接口 :

string GetNameByStudentID(int StudentID);

但隐式接口是由有效表达式组成的,考虑一个模板函数,像下面这样:

template <class T>
void TemplateFunction(T& w)
{
    if(w.size() > 10){}
}

T可以是int,可以double,也可以是自定义的类型。光看这个函数声明,我们不能确定T具体是什么,但我们知道,要想通过编译,T必须要支持size()这个函数。也就是说,T中一定要有这样的函数接口声明。

 ReturnValue size(); 

当然返回值ReturnValue不一定是int了,只要它能支持operator > (ReturnValue, 10)这样的运算即可。这种由表达式推判出来的函数接口,称之为隐式接口。

简言之,显式接口由函数签名式构成,隐式接口由有效的表达式组成。

下面讨论编译期多态的问题,我们在讨论继承时就已经多次提到“运行时多态”了,它伴随着virtual关键字,本质是一个虚表和虚指针,在类对象构造时,将虚指针指向了特定的虚表,然后运行时就会根据虚表的内容进行函数调用。

那么“编译期多态”又是什么呢,从字面上来看,它发生在编译阶段,实际上就是template 这个T的替换,它可以被特化为int,或者double,或者用户自定义类型,这一切在编译期就可以决定下来T到底是什么,编译器会自动生成相应的代码(把T换成具体的类型),这就是编译期多态做的事情了,它的本质可以理解成自动生成特化类型版本,T可以被替换成不同的类型,比如同时存在int版本的swap与double版本的swap,形成函数重载。简言之,运行时多态是决定“哪一个virtual函数应该被绑定”,而编译期多态决定“哪一个重载函数应该被调用”。

最后总结一下:

  1. class和template都支持接口与多态;
  2. 对classes而言,接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期;
  3. 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。

泛型是通过参数化类型来实现在同一份代码上操作多种数据类型。利用“参数化类型”将类型抽象化,从而实现灵活的复用,是编译期多态的一种表现形式;

泛型的优点:

  1. 可以做到最大的代码重用、保护类型的安全以及提高了程序运行的性能;
  2. 可以创建集合类;
  3. 可以创建自己的泛型接口、泛型方法、泛型类、泛型事件和泛型委托。
  4. 可以对泛型类进行约束,以访问特定数据类型的方法。
  5. 关于泛型数据类型中使用的类型的信息,可在运行时通过反射获取。

了解new_handler的行为

在声明于< new>的一个标准程序库中,有如下的接口:

void MyOutOfMemory()
 {
     cout << "Out of memory error!" << endl;
     abort();
 }
 int main()
 {
     set_new_handler(MyOutOfMemory);
     int *verybigmemory = new int[0x1fffffff];
     delete verybigmemory;
 }

注意这里面typedef了一个函数指针new_handler,它指向一个函数,这个函数的返回值为void,形参也是void。set_new_handler就是将new_handler指向具体的函数,在这个函数里面处理out of memory异常(函数末尾的throw()表示它不抛出任务异常),如果这个new_handler为空,那么这个函数没有执行,就会抛出out of memory异常。

 void MyOutOfMemory()
 {
     cout << "Out of memory error!" << endl;
     abort();
 }
 
 int main()
 {
     set_new_handler(MyOutOfMemory);
     int *verybigmemory = new int[0x1fffffff];
     delete verybigmemory;
 }

这里预先设定好new异常时调用的函数为MyOutOfMemory,然后故意申请一个很大的内存,就会走到MyOutOfMemory中来了。

最后总结一下:
set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用。

绝对不要以多态方式处理数组

#include <iostream>
using namespace std;

struct B
{
    virtual void print() const{cout<<"base print()"<<endl;}
};
struct D : B
{
    void print() const{cout<<"derived print()"<<endl;}
    int id;  //如果没有此句,执行将正确,因为基类对象和子类对象长度相同  
};

int fun(const B array[],int size)
{
    for(int i = 0;i<size;++i)
    {
        array[i].print();
    }
}

int main()
{
    B barray[5];
    fun(barray,5);
    D darray[5];
    fun(darray,5);
}

array[i] 其实是一个指针算术表达式的简写,它代表的其实是 *(array+i),array是一个指向数组起始处的指针。在 for 里遍历 array 时,必须要知道每个元素之间相差多少内存,而编译器则根据传入参数来计算得知为 sizeof(B),而如果传入的是派生类数组对象,它依然认为是 sizeof(B),除非正好派生类大小正好与基类相同,否则运行时会出现错误。但是如果我们设计软件的时候,不要让具体类继承具体类的话,就不太可能犯这种错误。(理由是,一个类的父类一般都会是一个抽象类,抽象类不存在数组)

千万不要重载 &&, || 和 , 操作符

int *pi = NULL;
if(pi != 0 && cout<<*pi<<endl) { }

上面的代码不会报错,虽然 pi 是空指针,但 && 符号采用"骤死式"评估方式,如果 pi == 0 的话,不会执行后面的语句。

不要重载这些操作符,是因为我们无法控制表达式的求解优先级,不能真正模仿这些运算符。操作符重载的目的是使程序更容易阅读,书写和理解,而不是来迷惑其他人。如果没有一个好理由重载操作符,就不要重载。而对于&&,||和“,”,很难找到一个好理由。

在 constructors 内阻止资源泄漏

这一条讲得其实是捕获构造函数里的异常的重要性。

堆栈辗转开解(stack-unwinding):如果一个函数中出现异常,在函数内即通过 try…catch 捕捉的话,可以继续往下执行;如果不捕捉就会抛出(或通过 throw 显式抛出)到外层函数,则当前函数会终止运行,释放当前函数内的局部对象(局部对象的析构函数就自然被调用了),外层函数如果也没有捕捉到的话,会再次抛出到更外层的函数,该外层函数也会退出,释放其局部对象……如此一直循环下去,直到找到匹配的 catch 子句,如果找到 main 函数中仍找不到,则退出程序。

#include <iostream>
#include <string>
#include <stdexcept>

class B
{
    public:
        B(const int userid_,const std::string& username_ = "",const std::string address_ = ""):
        userid(userid_),
        username(0),
        address(0)
        {
            username = new std::string(username_);
            throw std::runtime_error("runtime_error");  //构造函数里抛出异常的话,由于对象没有构造完成,不会执行析构函数
            address = new std::string(address_);
        }
        ~B()    //此例中不会执行,会导致内存泄漏
        {
            delete username;
            delete address;
            std::cout<<"~B()"<<std::endl;
        }
    private:
        int userid;
        std::string* username;
        std::string* address;
};

main()
{
    try { B b(1); } catch(std::runtime_error& error) { }
}

C++拒绝为没有完成构造函数的对象调用析构函数,原因是避免开销,因为只有在每个对象里加一些字节来记录构造函数执行了多少步,它会使对象变大,且减慢析构函数的运行速度。

一般建议不要在构造函数里做过多的资源分配,而应该把这些操作放在一个类似于 init 的成员函数中去完成。这样当 init 成员函数抛出异常时,如果对象是在栈上,析构函数仍会被调用(异常会自动销毁局部对象,调用局部对象的析构函数,见下面),如果是在堆上,需要在捕获 异常之后 delete 对象来调用析构函数。

禁止异常流出 destructors 之外

这一条讲得其实是捕获析构函数里的异常的重要性。第一是防止程序调用 terminate 终止(这里有个名词叫:堆栈辗转开解 stack-unwinding);第二是析构函数内如果发生异常,则异常后面的代码将不执行,无法确保我们完成我们想做的清理工作。

之前我们知道,析构函数被调用,会发生在对象被删除时,如栈对象超出作用域或堆对象被显式 delete (还有继承体系中,virtual 基类析构函数会在子类对象析构时调用)。除此之外,在异常传递的堆栈辗转开解(stack-unwinding)过程中,异常处理系统也会删除局部对象,从而调用局部对象的析构函数,而此时如果该析构函数也抛出异常,C++程序是无法同时处理两个异常的,就会调用 terminate()终止程序(会立即终止,连局部对象也不释放)。另外,如果异常被抛出,析构函数可能未执行完毕,导致一些清理工作不能完成。

所以不建议在析构函数中抛出异常,如果异常不可避免,则应在析构函数内捕获,而不应当抛出。 场景再现如下:

#include <iostream>

struct T
{
    T()
    {
        pi = new int;
        std::cout<<"T()"<<std::endl;
    }
    void init(){throw("init() throw");}
    ~T()
    {
        std::cout<<"~T() begin"<<std::endl;
        throw("~T() throw");
        delete pi;
        std::cout<<"~T() end"<<std::endl;
    }
    int *pi;
};

void fun()
{
    try{
        T t;
        t.init();
    }catch(...){}

//下面也会引发 terminate
    /*
    try
    {
        int *p2 = new int[1000000000000L];
    }catch(std::bad_alloc&)
    {
        std::cout<<"bad_alloc"<<std::endl;
    }
    */
}

void terminate_handler()
{
    std::cout<<"my terminate_handler()"<<std::endl;
}

int main()
{
    std::set_terminate(terminate_handler);
    fun();
}

在这里插入图片描述

了解 "抛出一个 exception ” 与 “传递一个参数” 或 “调用一个虚函数”之间的差异

抛出异常对象,到 catch 中,有点类似函数调用,但是它有几点特殊性:

 #include <iostream>
 
 void fun1(void)
 {
     int i = 3;
     throw i;
 }
 void fun2(void)
 {
     static int i = 10;
     int *pi = &i;
     throw pi; //pi指向的对象是静态的,所以才能抛出指针
 }
 main()
 {
     try{
         fun1();
     }catch(int d)
     {
         std::cout<<d<<std::endl;
     }
     try{
         fun2();
     } catch(const void* v)
     {
         std::cout<<*(int*)v<<std::endl;
     }
 }

如果抛出的是 int 对象的异常,是不能用 double 类型接收的,这一点跟普通函数传参不一样。异常处理中,支持的类型转换只有两种,一种是上面例子中演示的从"有型指针"转为"无型指针",所以用 const void* 可以捕捉任何指针类型的 exception。另一种是继承体系中的类转换,可见下一条款的例子。

另外,它跟虚拟函数有什么不同呢?异常处理可以出现多个 catch 子句,而匹配方式是按先后顺序来匹配的(所以如exception异常一定要写在runtime_error异常的后面,如果反过来的话,runtime_error异常语句永远不会执行),而虚函数则是根据虚函数表来的。

1.函数return值与try块throw exception、函数接收参数与catch字句捕获异常相当类似(不仅声明形式相像,函数参数与exception传递方式都有三种:by value,by reference ,by pointer(本质上也是by value) )。

2.尽管函数调用与异常抛出相当类似,“从抛出端传递一个exception到catch子句”和“从函数调用端传递一个实参到被调函数参数”仍然大有不同:

  • 1)调用一个函数,控制权会最终回到调用端(除非函数失败以致无法返回),但是抛出一个exception,控制权不会再回到抛出端;
    可以简单理解函数调用作用域是“外—里—外”的转换,而异常抛出是“里—外—···”的转换(只是便于理解,实际上这个比方并不正确)
  • 2)如果函数调用的参数是按引用传递的,那么实参不会被复制,但无论catch接收的异常是按引用还是按值传递,被抛出的异常对象至少被复制一次,原因在于栈展开过程中局部对象都被销毁,因而需要产生一个临时对象保存被throw的异常,这与函数return时用一个临时对象来暂时保存return的对象是一样的(函数return存在NRV(有的也叫RVO)优化,可以省略调用拷贝构造函数)。也就是说,在第一个catch子句接受异常时,那个异常已经是被复制过一次的临时对象,如果catch子句的参数是按值传递,那么临时对象还需要再被复制一次。因此异常处理通常要付出较高的代价。
  • 3)函数可以返回引用,catch子句不可能重新抛出一个引用,对于以下代码:
try{
    throw Derived;
}
catch(Base & tmp){
    throw tmp;
}

catch子句重新throw的过程中创建临时对象并调用拷贝构造函数,由于构造函数不可能为虚(虽然可以采取其他方式形成虚的"伪构造函数"),这意味着如果经由catch子句抛出的异常已经变为了Base类型(尽管传入的时候是按引用传递的),此时异常是当前exception的副本。如果要重新抛出Derived类型对象可以采用以下代码:

try{
    throw Derived
}
catch(Based& tmp){
    throw}

这样抛出的是当前的exception

  • 4)函数调用与异常抛出参数匹配规则不同,如果有多个重载函数,那么选择参数最为匹配的那个,找不到匹配的函数则进行实参的转换尽量匹配上,有多个相当匹配的函数则发生二义性,也就是说,函数匹配采用“最佳吻合”策略;

异常抛出则不同,catch子句依出现顺序做匹配尝试,一旦找到一个“相对兼容的”类型就视为匹配成功,就算后面有完全匹配的类型也会被无视,也就是说,异常抛出的参数匹配采用“最先吻合”策略,也正是由于这种策略,异常抛出的参数所允许的转换比函数实参匹配所允许的转换要严格得多,只允许以下转换:

  • 1)“继承架构中的类转换”:派生类异常可以被基类参数捕获,因此catch子句出现顺序应该是先派生类再基类
  • 2)非const到const的转换
  • 3)数组转为数组类型的指针
  • 4)其他指针转“无型指针”(void*指针)

以 by reference 方式捕捉 exceptions

用指针方式来捕捉异常,上面的例子效率很高,没有产生临时对象。但是这种方式只能运用于全局或静态的对象(如果是 new 出来的堆中的对象也可以,但是该何时释放呢?)身上,否则的话由于对象离开作用域被销毁,catch中的指针指向不复存在的对象。接下来看看对象方式和指针方式:

#include <iostream>
#include <stdexcept>

class B
{
    public:
        B(){}
        B(const B& b){std::cout<<"B copy"<<std::endl;}
        virtual void print(void){std::cout<<"print():B"<<std::endl;}
};

class D : public B
{
    public:
        D():B(){}
        D(const D& d){std::cout<<"D copy"<<std::endl;}
        virtual void print(void){std::cout<<"print():D"<<std::endl;}
};

void fun(void)
{
    D d;
    throw d;
}
main()
{
    try{
        fun();
    }catch(B b) //注意这里
    {
        b.print();
    }
}

上面的例子会输出:
在这里插入图片描述
可是如果把 catch(B b) 改成 catch(B& b) 的话,则会输出:
在这里插入图片描述

该条款的目的就是告诉我们,请尽量使用引用方式来捕捉异常,它可以避免 new 对象的删除问题,也可以正确处理继承关系的多态问题,还可以减少异常对象的复制次数。

利用 destructor 避免泄露资源

  1. “函数抛出异常的时候,将暂停当前函数的执行,开始查找匹配的catch语句。首先检查throw本身是否在try块内部,如果是,检查与该try块相关的catch语句,看是否其中之一与被抛出的对象相匹配。如果找到匹配的catch,就处理异常;如果找不到,就退出当前函数(释放当前函数的内存并撤销局部对象),并继续在调用函数中查找。”(《C++ Primier》)这称为栈展开。
  2. 函数执行的过程中一旦抛出异常,就停止接下来语句的执行,跳出try块(try块之内throw之后的语句不再执行)并开始寻找匹配的catch语句,跳出try块的过程中,会适当的撤销已经被创建的局部对象,运行局部对象的析构函数并释放内存。
  3. 如果在throw之前恰好在堆中申请了内存,而释放内存的语句又恰好在throw语句之后的话,那么一旦抛出异常,该语句将不会执行造成内存泄露问题。
  4. 解决办法是将指针类型封装在一个类中,并在该类的析构函数中释放内存。这样即使抛出异常,该类的析构函数也会运行,内存也可以被适当的释放。C++ 标准库提供了一个名为auto_ptr的类模板,用来完成这种功能。

“C++ 只会析构已完成的对象”,“面对未完成的对象,C++ 拒绝调用其析构函数”,因为对于一个尚未构造完成的对象,构造函数不知道对象已经被构造到何种程度,也就无法析构。当然,并非不能采取某种机制使对象的数据成员附带某种指示,“指示constructor进行到何种程度,那么destructor就可以检查这些数据并(或许能够)理解应该如何应对。但这种机制无疑会降低constructor的效率,,处于效率与程序行为的取舍,C++ 并没有使用这种机制。所以说,”C++ 不自动清理那些’构造期间跑出exception‘的对象“。

terminate函数在exception传播过程中的栈展开(stacking-unwinding)机制中被调用;第二,它可以协助确保destructors完成其应该完成的所有事情

利用重载技术避免隐式类型转换

1)正如条款19和条款20所言, 临时对象的构造和析构会增加程序的运行成本,因此有必要采取措施尽量避免临时对象的产生.条款20介绍了一种用于消除函数返回对象而产生临时对象的方法——RVO,但它并不能解决隐式类型转换所产生的临时对象成本问题.在某些情况下,可以考虑利用重载技术避免隐式类型转换.

2)考虑以下类UPInt类用于处理高精度整数:

class UPInt{
public:
    UPInt();
    UPInt(int value);
    ...
};
const UPInt operator+(const UPInt& lhs,const UPInt& rhs);
那么以下语句可以通过编译:
UPInt upi1;
...
UPInt  upi2=2+upi1;
upi3=upi1+2;

原因在于UPInt的单int参数构造函数提供了一种int类型隐式转换为UPInt类型的方法:先调用UPInt的单int参数构造函数创建一临时UPInt对象,再调用operator+.此过程产生了一临时对象,用于调用operator+并将两个UPInt对象相加,但实际上要使int与UPInt相加,不需要隐式类型转换,换句话说,隐式类型转换只是手段,而不是目的.要避免隐式类型转换带来的临时对象成本,可以对operator+进行重载:

UPInt operator+(int,const UPInt&);
UPInt operator+(const UPInt&,int);

3)在2中用函数重载取代隐式类型转换的策略不局限于操作符函数,在string与char*,Complex(复数)与int,double等的兼容方面同样可以采用此策略,但此策略要权衡使用,因为在增加一大堆重载函数不见得是件好事,除非它确实可以使程序效率得到大幅度提高.

template<typename InputIterator, typename Function>
Function for_each(InputIterator beg, InputIterator end, Function f) {
  while(beg != end) 
    f(*beg++);
}

构造函数为什么不能使虚函数

  1. 虚函数对应一个虚指针,虚指针其实是存储在对象的内存空间的。如果构造函数是虚的,就需要通过虚指针执行那个虚函数表(编译期间生成属于类)来调用,可是对象还没有实例化,也就是内存空间还没有,就没有虚指针,所以构造函数不能是虚函数
  2. 虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数

构造函数可以抛出异常?

  1. 构造函数中抛出异常,会导致析构函数不能被调用,但对象本身已申请到的内存资源会被系统释放(已申请到资源的内部成员变量会被系统依次逆序调用其析构函数)。
  2. 因为析构函数不能被调用,所以可能会造成内存泄露或系统资源未被释放。
  3. 构造函数中可以抛出异常,但必须保证在构造函数抛出异常之前,把系统资源释放掉,防止内存泄露。

C++标准指明析构函数不能、也不应该抛出异常

上面的论述C++异常处理模型它其实是有一个前提假设——析构函数中是不应该再有异常抛出的。试想,如果对象出了异常,现在异常处理模块为了维护系统对象数据的一致性,避免资源泄漏,有责任释放这个对象的资源,调用对象的析构函数,可现在假如析构过程又再出现异常,那么请问由谁来保证这个对象的资源释放呢?而且这新出现的异常又由谁来处理呢?不要忘记前面的一个异常目前都还没有处理结束,因此这就陷入了一个矛盾之中,或者说无限的递归嵌套之中。所以C++标准就做出了这种假设,当然这种假设也是完全合理的,在对象的构造过程中,或许由于系统资源有限而致使对象需要的资源无法得到满足,从而导致异常的出现,但析构函数完全是可以做得到避免异常的发生,毕竟你是在释放资源呀!

假如无法保证在析构函数中不发生异常,怎么办? 虽然C++标准中假定了析构函数中不应该,也不永许抛出异常的。但实际的软件系统开发中是很难保证到这一点的。所有的析构函数的执行过程完全不发生一点异常,这根本就是天方夜谭,或者说自己欺骗自己算了。而且有时候析构一个对象(释放资源)比构造一个对象还更容易发生异常,例如一个表示引用记数的句柄不小心出错, 结果导致资源重复释放而发生异常,当然这种错误大多时候是由于程序员所设计的算法在逻辑上有些小问题所导致的,但不要忘记现在的系统非常复杂,不可能保证 所有的程序员写出的程序完全没有bug。因此杜绝在析构函数中决不发生任何异常的这种保证确实是有点理想化了。

more effective c++提出两点理由(析构函数不能抛出异常的理由)

  • 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
  • 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

那么当无法保证在析构函数中不发生异常时, 该怎么办?

  • 其实还是有很好办法来解决的。那就是把异常完全封装在析构函数内部,决不让异常抛出函数之外。这是一种非常简单,也非常有效的方法。
~ClassName()
{
  try{
      do_something();
  }

  catch(){  //这里可以什么都不做,只是保证catch块的程序抛出的异常不会被扔出析构函数之外。

   }
}

析构函数中抛出异常时概括性总结

  • C++中析构函数的执行不应该抛出异常;
  • 假如析构函数中抛出了异常,那么你的系统将变得非常危险,也许很长时间什么错误也不会发生;但也许你的系统有时就会莫名奇妙地崩溃而退出了,而且什么迹象也没有,崩得你满地找牙也很难发现问题究竟出现在什么地方;
  • 当在某一个析构函数中会有一些可能(哪怕是一点点可能)发生异常时,那么就必须要把这种可能发生的异常完全封装在析构函数内部,决不能让它抛出函数之外(这招简直是绝杀!呵呵!);
  • 2
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秋风遗梦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值