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

C++必备基础知识(x+1) -《Effective C++必懂条款2》

程序员文章站 2024-03-22 14:26:10
...

了解new_handler的行为

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

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();
}

C++必备基础知识(x+1) -《Effective C++必懂条款2》

了解 "抛出一个 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();
    }
}

上面的例子会输出:
C++必备基础知识(x+1) -《Effective C++必懂条款2》
可是如果把 catch(B b) 改成 catch(B& b) 的话,则会输出:
C++必备基础知识(x+1) -《Effective C++必懂条款2》

该条款的目的就是告诉我们,请尽量使用引用方式来捕捉异常,它可以避免 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++);
}