1. 程式人生 > 其它 >C++必備基礎知識(x+1) -《Effective C++必懂條款2》

C++必備基礎知識(x+1) -《Effective C++必懂條款2》

技術標籤:秋招(後臺開發崗)知識總結c++面試後端軟體開發

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

在這裡插入圖片描述

瞭解 "丟擲一個 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++);
}