1. 程式人生 > >【Effective C++】實現

【Effective C++】實現

文章目錄

一、儘可能延後變數定義式的出現時間

1、變數定義時間點的問題

  在程式中定義一個變數,當控制流(control flow)到達這個變數定義時,程式就要承受變數的構造成本,當控制流離開這個作用域時,程式也要承受析構成本。無論這個變數是否使用,都要承受這些成本。應該儘量避免這種情形。或許你認為自己不會這樣使用,但也未必。例如要寫一個加密函式,但加密的密碼要足夠長。如果密碼太短,會丟擲一個異常logic_error。

std::string encryptPassword(const std::string& psaaword)
{
    using namespace std;
    string encrypted;
    if(password.length()<MinimumPasswordLength)
    {
        throw logic_error("Password is too short");
    }
    ……//加密密碼,把加密結果放到encrypted內
    return encrypted;
}

  如果這個函式丟擲異常,那麼變數encrypted即便是未使用,也會執行建構函式和解構函式。

  那麼迴圈時怎麼做呢?把變數定義在迴圈外還是迴圈內?

Widget w;//定義在迴圈外
for(int i=0;i < n;++i)
    w=……;
    ……
}
for(int i=0;i<n;++i){
    Widget w(……);//定義並賦值
    ……
}
程式碼A:1個建構函式+1個解構函式+n個賦值操作
程式碼B:n個建構函式+n個解構函式

2、請記住

  • 儘可能延後變數定義式的出現。這樣做可增加程式的清晰度並改善程式效率。

二、儘量少做轉型動作

1、C++四種轉型動作

  • static_cast(expression):用來強迫隱式轉換,例如將non-const物件轉為const物件,幾乎舊式轉型的操作都可以通過static_cast來替換實現。但它無法將const轉為non-const——這個只有const_cast才辦得到。

  • const_cast(expression):通常被用來將物件的常量性移除。它也是唯一有此能力的C+±style轉型操作符。

  • dynamic_cast(expression):主要用來執行“安全向下轉型”(基類向子類的向下轉型(Down Cast)),(“安全向下轉型”,一般而言,基類向子類轉型為不安全轉型,會引發程式異常,使用dynamic_cast,這類轉型則會返回一個Null值,消除了異常,實現了“安全”)也就是用來決定某物件是否歸屬繼承體系中的某個型別。它是唯一無法由舊式語法執行的動作,也是唯一可能耗費重大執行成本的轉型動作。表示式dynamic_cast<T*>(a) 將a值轉換為型別為T的物件指標。如果型別T不是a的某個基型別,該操作將返回一個空指標。

  • reinterpret_cast(expression):意圖執行低階轉型,實際動作(及結果)可能取決於編譯器,這也就表示它不可移植。

為了相容,舊式的轉型仍然合法,但是更提倡用新式的形式。因為:

  • 新式轉型很容易被辨識出來,可以很快找到程式碼中有哪些轉型。

  • 新式轉型動作的目標愈窄化,編譯器愈可能診斷出錯誤的運用

2、請記住

  • 應該儘量少使用轉型,尤其是在注重效率的程式碼中使用dynamic_cast。如果需要轉型,試著設計無需轉型代替。

  • 如果必須使用轉型,把它隱藏在函式內,客戶通過介面使用,而不是由客戶來實現轉型。

  • 使用新式的C++ style轉型來代替舊的轉型,因為新式的轉型很容易辨識出來,而且它們有分類。


三、避免返回handles指向物件內部成分

1、返回一個“代表物件內部資料”的handle存在的問題

class Point   // 表示點  
{
    public:
        point(int x, int y);
        ...
        void setX(int newVal);
        void setY(int newVal);
        ...
};
struct RectData      // 表示 矩陣
{
    Point ulhc;     // 左上角
    Point lrhc;     // 右下角
};
class Rectangle
{
    public:
        ...
        // 錯誤,,如果保證安全可以對它們的返回型別加上const
        Point& upperLeft() const { return pData->ulhc; } 
        Point& lowerRight() const { return pData->lrhc; }
        ...
    private:
        std::tr1::shared_ptr<RectData> pData; 
};
// 考慮以下呼叫
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);
// 下面呼叫,rec左上角從(0,0)變成(50,0)
rec.upperLeft().setX(50);

【問題1】:

  reference、指標和迭代器統統都是所謂的handles(號碼牌,用來取得某個物件),而返回一個“代表物件內部資料”的handle,隨之而來的便是“降低物件封裝性”的風險。同時,一如稍早所見,它也可能導致“雖然呼叫const成員函式卻造成物件狀態被更改”。任何呼叫者返一個指標(reference/迭代器)指向一個“訪問級別較低”的內部成員,都會導致“降低物件封裝性”的風險。

【問題2】:

  返回“代表物件內部”的handles有可能導致dangling handles(空懸的號碼牌):handles所指東西(的所屬物件)不復存在,有空懸的問題。關鍵在於:一旦有個handle被傳出去,你就要承擔“handle比其所指物件壽命更長”的風險。(物件只有語句內的生命週期;或者物件生命週期在語句塊,離開語句塊物件將被銷燬;對諸如此情況的物件返回reference,等同於返回 區域性物件的reference,將導致空懸指標。)

2、請記住

  • 避免返回handles(包括reference、指標、迭代器)指向物件內部。遵守這個條款可增加封裝性,幫助const成員函式的行為像個const,並將發生“虛吊號碼牌”的可能性降至最低。

四、為“異常安全”而努力是值得的

1、異常安全函式的三個保證

  • 基本承諾:如果異常被丟擲,程式內的任何事物仍然保持在有效狀態下。沒有任何物件或資料結構會因此而敗壞,所有物件都處於一種內部前後一致的狀態。

  • 強烈保證:如果異常被丟擲,程式狀態不改變。呼叫這樣的函式需有這樣的認知:如果函式成功,就是完全成功,如果函式失敗,程式會回覆到”呼叫函式之前“的狀態。

  • 不拋擲(nothrow)保證:承諾絕不丟擲異常,因為它們總能完全原先承諾的功能。作用於內建型別身上的所有操作都提供nothrow保證。這是異常安全碼中一個必不可少的關鍵基礎材料。

  有個一般化的設計策略很典型地會導致”強烈保證“,這個策略被稱為copy and swap。原則很簡單:為你打算修改的物件(原件)做出一份副本,然後在那副本身上做一切必要修改。若有任何修改動作丟擲異常,原物件仍保持未改變狀態。待所有改變都成功後,再將修改過的那個副本和原物件在一個不丟擲異常的操作中置換(swap)

【總結】:

  提供函式異常安全性,通常需要作如下考慮:以物件管理資源(RAII),那可阻止資源洩漏;挑選三個“異常安全保證”中的某一個實施於你所寫的每一個函式身上。

2、請記住

  • 異常安全函式即使發生異常也不會洩漏資源或允許任何資料結構敗壞。這樣的函式區分為三種可能的保證:基本型、強烈型、不拋異常型

  • “強烈保證”往往能夠以copy-and-swap實現出來,但“強烈保證”並非對所有函式都可實現或具備現實意義(效率和複雜程度帶來的成本)。

  • 函式提供的“異常安全保證”通常最高只等於其所呼叫之各個函式的“異常安全保證”中的最弱者。


五、透徹瞭解inline函式的裡裡外外

1、inline函式的特點

  inline函式,比巨集好得多,“免除函式呼叫成本”;編譯器最優化機制能對它(函式本體)執行語境相關最優化。然而,過多熱衷inlining會增加目標碼大小,造成程式體積太大(對可用空間而言),這進而會導致換頁行為,降低指令快取記憶體裝置的擊中率,以及伴隨而來的效率損失。

  切記,inline只是對編譯器的一個申請(編譯器不一定批准),不是強制命令。這項申請可用隱喻提出,也可以明確提出。隱喻方式是將函式定義於class定義式內,明確宣告inline函式的做法則是在其定義式前加上關鍵字inline:

class Person
{
    public:
        ....
        int age() cosnt { return theAge; }      // 一個隱喻的inline申請
        ...
    private:
        int theAge;
};
// 明確宣告inline函式的做法則是在其定義式前加上關鍵字inline。
template<typename T>
inline const T& std::max(const T& a, const T& b)     // 明確申請inline
{ return a < b ? b : a; }

2、inline函式為什麼放到標頭檔案

  • inline函式和templates通常都被定義於標頭檔案。這不是巧合。大多數建置環境(build environment)在編譯過程中進行inlining,將一個“函式呼叫”替換為“被呼叫函式的本體”,在替換時需要知道這個函式長什麼樣子。Inlining在大多數C++中是編譯期行為。

  • Templates通常也放置在標頭檔案,因為它一旦被使用,編譯器為了將它具體化,也需要知道它長什麼樣子。(有些編譯環境可以在連結期間才執行template具體化,但是編譯期間完成的更常見)。

  大部分編譯器拒絕太過複雜的inlining函式(例如有迴圈或遞迴)。virtual函式也不能是inline函式,因為virtual函式是直到執行時才確定呼叫哪個函式,而inline是執行前將呼叫動作替換為函式本體。

3、什麼時候不應該用inline

  • 大部分編譯器拒絕將太過複雜(例如帶有迴圈或遞迴)的函式inlining

  • 所有對virtual函式的呼叫(除非是最平淡無奇的)也都會使inlining落空。因為virtual意味“等待,直到執行期才確定呼叫哪個函式”,而inline意味“執行前,先將呼叫動作替換為被呼叫函式的本體”。如果編譯器不知道該呼叫哪個函式,則拒絕將函式本體inlining。

  • 有時候雖然編譯器有意願inlining某個函式,還是可能為該函式生成一個函式本體。例如,如果程式要取某個inline函式的地址,編譯器通常必須為此函式生成一個outlined函式本體。畢竟編譯器哪有能力提出一個指標指向並不存在的函式呢?於此並提的是,編譯器通常不對“通過函式指標而進行的呼叫”實施inlining,這意味對inline函式的呼叫有可能被inlined,也可能不被inlined。

  • 建構函式和解構函式往往是inlining的糟糕候選人。

4、請記住

  • 將大多數inlining限制在小型、被頻繁呼叫的函式身上。這可使日後的除錯過程和二進位制升級更容易,也可使潛在的程式碼膨脹問題最小化,使程式的速度提升機會最大化。

  • 不要只因為function templates出現在標頭檔案,就將它們宣告為inline。


六、將檔案的編譯依存關係降到最低

1、降低檔案編譯依存關係

  假設你對C++程式的某個class實現檔案做了些輕微修改。注意,修改的不是class介面,而是實現,而且只改private成分。而當你編譯的時候,卻編譯了整個工程,這絕對不可被接受。問題出在C++並沒有把“將介面從實現中分離”這事做得很好。Class的定義式不只詳細敘述了class介面,還包括十足的實現細目。如:

// 如果沒有加入一下標頭檔案,Person無法通過編譯
// 因為編譯器沒有取得其實現程式碼所用到的classes string, Date 和 Address的定義式
#include <string>
#include "date.h"
#include "address.h"
//
class Person
{
    public:
        Person(const std::string& name, const Date& birthDate, const Address& addr);
        std::string name() const;
        std::string birthDate() const;
        std::string address() const;
        ....
    private:
        std::string theName;      //實現細目
        Date thBirthDate;
        Address theAddress;
};

  #include標頭檔案,這麼一來卻在Person定義檔案和其含入檔案之間形成了一種編譯依存關係。如果這些標頭檔案中有任何一個被改變,或這些標頭檔案所倚賴的其他標頭檔案有任何改變,那麼每一個含入Person class的檔案就得重新編譯,任何使用Person class的檔案也必須重新編譯。(其實,現實中有很多的工程專案即是如此,改動標頭檔案便會導致所有關聯的檔案重新編譯,所以在實際中儘量不去改動到標頭檔案)。

【解決方案1】:

  製造Handle class:“將物件實現細目隱藏於一個指標背後”(將物件實現細目抽離到另一個類裡面,在原來類中保留一個指向實現細目所在類的指標,也即pimpl)。針對Person:把Person分割為兩個classes,一個只提供介面,另一個負責實現該介面:

#include <string>    // 標準程式庫元件不該被前置宣告
#include <memory>    // 為了tr1::shared_ptr而含入

class PersonImpl;    // Person實現類的前置宣告
class Date;
class Address;

class Person
{
    public:
        Person(const std::string& name, const Date& birthDate, const Address& addr);
        std::string name() const;
        std::string birthDate() const;
        std::string address() const;
        ....
    private:
    	 // 智慧指標(條款13),指向實現物
        std::tr1::shared_ptr<PersonImpl> pImpl;     
};

  這個分離的關鍵在於以“宣告的依存性”替換“定義的依存性”,那正是編譯依存性最小化的本質:現實中讓標頭檔案儘可能自我滿足,萬一做不到,則讓它與其他檔案內的宣告式(而非定義式)相依。

【解決方案2】:

  令Person成為一種特殊的abstract base class(抽象基類),稱為Interface class(將原來類裡面的介面抽離到另一個類(抽象基類)裡面,包含實現細目的類繼承此抽象基類,同時實現其抽象介面.注意,抽象基類裡面的介面為static並且virtual,迫使派生類必須自己重新實現這個繼承而來的介面.).這種class的目的是詳細一一描述derived classes的介面,因此它通常不帶成員變數,也沒有建構函式,只有一個virtual解構函式以及一組pure virtual函式,用來敘述整個介面。

class Person
{
    public:
        .....
        virtual ~Person();
        virtual std::string name() const = 0;
        virtual std::string birthDate() const = 0;
        virtual std::string address() const = 0;
        ..... 
         // 返回一個tr1::shared_ptr,指向一個新的Person,並以給定引數初始化
        static std::tr1::shared_ptr<Person>    
        create(const std::string& name,        
               const Date& birthday,
               const Address& addr);
        .....
};

// 如下使用
std::string name;
Date dateOfBirth;
Address address;
...
//建立一個物件,支援Person介面
shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() // 提供Person的介面使用這個物件
              << " was born on " 
              << pp->birthDate() 
              << " and now lives at " 
              << pp->address();

  當然,支援Interface class介面的那個具象類必須被定義出來,而且真正的建構函式必須被呼叫。一切都在virtual建構函式實現碼所在的檔案內祕密發生,如下:

class RealPerson:public Person
{
    public:
        RealPerson(const std::string& name, const Date& birthday, 
                        const Address& addr)
        :theName(name), theBirthDate(birthday), theAddress(addr)
        {}
        virtual ~RealPerson() {}
        std::string name() const;
        std::string birthDate() const;
        std::string address() const;
    private:
        std::string theName;
        Date theBirthDate;
        Address theAddress;
};
// 有了RealPerson之後,就可以寫出Person::create的實現了
std::tr1::shared_ptr<Person> Person::create(const std::string& name, 
                                                                    const Date& birthday, 
                                                                    const Address& addr)
{
    return 
        std::tr1::shared_ptr<Person> (new RealPerson(name, birthday, addr));  // new 不同的派生類物件,但都用抽象類指向它
}

2、請記住

  • 支援“編譯依存性最小化”的一般構想是:相依於宣告式,不要相依於定義式。基於此構想的兩個手段是Handle classes 和 Interface classes。

  • 程式庫標頭檔案應該以“完全且僅有宣告式”的形式存在。這種做法不論是否涉及templates都適用。