1. 程式人生 > 其它 >Effective C++讀書筆記~5 實現

Effective C++讀書筆記~5 實現

目錄

條款26:儘可能延後變數定義式的出現時間

Postpone variable definitions as long as possible.

為什麼要延後變數定義?

因為定義一個變數會有構造成本和析構成本,而該變數可能從始至終並未使用過。因此,如果能儘量避免,就能減少這些成本。

如何延後變數定義?

1)不要過早定義變數,即將使用的時候定義

// 過早定義變數encrypted
string encrypPassword(const string& password)
{
    using namespace std;
    string encrypted; // 提前定義變數 encrypted, 儲存加密後的密碼
    if (password.length() < MinimumPasswordLength) { // 如果丟擲異常, 就不會使用變數encrypted
        throw logic_error("Password is too short");
    }
    ...
    return encrypted;
}

==> 改進
// 延後定義變數encrypted
string encrypPassword(const string& password)
{
    using namespace std;
    
    if (password.length() < MinimumPasswordLength) { // 可能丟擲異常
        throw logic_error("Password is too short");
    }
    string encrypted; // 延後定義變數 encrypted, 儲存加密後的密碼
    ...
    return encrypted;
}

2)定義物件時,提供構造引數
如果不提供構造引數,意味著呼叫default建構函式。條款4解釋了“通過default建構函式構造物件,然後賦值”的效率比“直接在構造時指定初值”更低。

void encrpyt(string& s) // 對s加密
{
    ...
}

// default構造物件
string encryptPassword(const string& password)
{
    ...
    string encrypted; // 已延後定義encrypted. default建構函式 構造encrypted
    encrypted = password; // 賦值給encrypted
    encrpyt(encrypted); 
    return encrypted;
}

==> 改進
// 帶引數構造物件
string encryptPassword(const string& password)
{
    ...
    string encrypted(password); // 已延後定義encrypted. 引數構造encrypted (copy 構造)
    encrpyt(encrypted);
    return encrypted;
}

3)迴圈體內 or 外定義變數?
對於迴圈,變數是定義在迴圈體外(方法A),還是迴圈體內(方法B)?
先看下面的例子,

// 方法A:定義於迴圈外
Widget w;
for (int i = 0; i < n; ++i) {
    w = 取決於i的某個值;
    ...
}

// 方法B:定義於迴圈體內
for (int i = 0; i < n; ++i) {
    Widget w(取決於i的某個值);
    ...
}

Widget函式內部定義變數的開銷:
方法A:1個建構函式 + 1個解構函式 + n個賦值操作;
方法B:n個建構函式 + n個解構函式;

當class的賦值成本明顯低於構造 + 析構成本時,特別n較大時,A方法較好;
否則,B方法較好,因為B的程式更容易理解。

也就是說,當1)明確知道賦值成本比“構造+析構”成本低,2)正在處理程式碼的效率高度敏感時,選擇方法A;否則選擇方法B。

小結

1)儘可能延後變了定義式的出現,因為可以增加程式的清晰度並改善程式效率。

[======]

條款27:儘量少做轉型動作

Minimize casting.

為什麼要減少轉型動作?

轉型(casts)破壞了型別系統(type system),可能導致任何種類的麻煩,難以辨識。

轉型語法

舊式轉型:
1)C風格轉型

(T)expression // 將expression轉型為T

2)函式風格轉型

T(expression) // 將expression轉型為T

C++新式轉型(稱為new-style或C++-style casts):
4種

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

各個轉型目的:
1)const_cast 通常被用來將物件的常量性移除(cast away the constness)。唯一有此能力的C++-style轉型操作符。
non-const member函式呼叫const member時,const_cast可用於去掉常量性。

2)dynamic_cast 主要用來執行“安全向下轉型”(safe downcasting),也就是用來決定某物件是否歸屬繼承體系中的某個型別。唯一無法由舊式語法執行的動作,也是唯一可能耗費重大執行成本的轉型動作。
一般情況下,不允許基類指標轉型為派生類指標或引用,因為編譯器並不知道執行時指標所指向的實際物件,是否為派生類物件。而dynamic_cast允許基類指標在執行時安全地轉型為派生類指標。

3)reinterpret_cast 意圖執行低階轉型,實際動作(及結果)可能取決於編譯器,意味著它不可移植。
慎用。

int *ip;
char *pc = reinterpret_cast<char *>(ip); // pointer from int* to char*

string str(pc); // 可能導致程式異常

4)static_cast 用來強迫隱式轉換,例如將non-const物件轉為const物件(條款3),或將int轉為double等。可以完成1)~3)的多種反向轉換。但無法將const轉為non-const(去掉常量性) -- 因為這是隻有const_cast才辦得到。

舊式轉型與新式轉型的選擇

舊式轉型仍然合法,但新式轉型更受歡迎。原因在於:
1)容易在程式碼中被辨識(人眼或工具如grep);
2)各轉型動作目標越窄化,編譯器越可能診斷出錯誤的應用;

除了explicit建構函式轉型,其他情況推薦使用新式轉型。

去掉轉型動作

有時,容易寫出某些似是而非的程式碼,在其他語言可能正確,但在C++則不正確。

1)去掉static_cast案例

class Windows { // base class
public:
    virtual void onResize() {...} // base onResize實現
    ...
};

class SpecialWindow: public Window { // derived class
public:
    virtual void onResize() { // derived onResize實現
        static_cast<Window>(*this).onResize(); // 錯誤: 將*this轉型為Window(副本), 然後呼叫其onResize
        
        ... // SpecialWindow專屬行為, 作用於*this物件
    }
};

乍一看,並沒有什麼問題。然而,仔細分析derived class的onResize中轉型行為,存在很大問題。"static_cast(this).onResize()"首先將 "this 物件的base class"拷貝構造一個臨時副本,然後呼叫臨時副本的onResize函式。呼叫的onResize函式既不是當前物件上的函式,也不是當前物件基類那部分的函式,而是基類物件的副本的函式。也就是說,如果onResize如果修改了基類資料(畢竟沒有const限定),而下面的SpecialWindow也修改了物件,就造成了基類和派生類資料的不一致。
解決辦法:去掉轉型動作,代之以你真正想說的話。
不要哄騙編譯器將*this視為一個base class物件,只是想呼叫base class版本的onResize函式,令其作用於當前物件身上。可以這麼寫:

class SpecialWindow: public Window {
public:
    virtual void onResize() {
        Window::onResize(); // 呼叫Window::onResize作用於*this身上
        ...
    }
};

2)去掉dynamic_cast案例
dynamic_cast的需要實現版本執行速度相當慢,儘量減少使用次數。
為什麼需要dynamic_cast?
通常是因為你想在一個你認定為derived class物件身上執行derived class操作函式,但你手上只有一個“指向base”的pointer或reference,你只能靠它們來處理物件。有2個一般性做法可以避免該問題:
(1)使用容器,並在其中儲存直接指向derived class物件的指標(通常是智慧指標,條款13),以便消除“通過base介面處理物件”的需要。
假設之前的Windows/SpecialWindow繼承體系的例子中,只有SpecialWindow才支援閃爍效果。

// 使用dynamic_cast效率低下做法
class Window { ... };
class SpecialWindow: public Window {
public:
    void blink();
};

typedef vector<shared_ptr<Window>> VPW; // vector存放Window物件的智慧指標
VPW winPtrs;
... // 往vector裝資料等操作
for (VPW::iterator iter = winPtrs.begin(); iter != winPtr.end(); ++iter) {
    if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()) // 使用了dynamic_cast
        psw->blink();
}

==> 應該改成
// 去掉了dynamic_cast轉型, 更高效
typedef vector<shared_ptr<SpecialWindow>> VPW; // vector存放SpecialWindow物件的智慧指標
VPSW winPtrs;
... // 往vector裝資料等操作
for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) // 不使用了dynamic_cast
    (*iter)->blink();

這種改動方式使用簡單,而且安全,但有一個缺點:無法在同一容器內 儲存指標以指向所有可能的各種Window派生類。如果要處理多種視窗型別(Window派生類),可能需要多個容器,並且它們都必須具備型別安全性(type-safe)。

(2)在base class內提供virtual函式,做想對各個Window派生類做的事情。因為指標指向的物件會在執行時呼叫virtual函式,以實現各自想做的事情。

// 為base class新增virtual函式
class Window {
public:
    virtual void blink() { } // 預設程式碼“什麼都沒做”,可能是個糟糕的主意, 見條款34. 這裡是為了演示
    ...
};

class SpecialWindow: public Window {
public:
    virtual void blink() { ... } // 在該class內, blink做符合當前class應該做的事情
};

typedef vector<shared_ptr<Window>> VPW; // vector元素還是使用指向base class Window物件的智慧指標
VPW winPtrs;
...// 往vector裝資料等操作
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
    (*iter)->blink(); // 這裡沒有dynamic_cast, 執行時會自動呼叫derived class對應virtual函式

小結

1)如果可以,儘量避免轉型,特別是注重效率的程式碼中,應極力避免dynamic_casts。如果有個設計需要轉型動作,嘗試發展無需轉型的替代設計。
2)如果轉型是必要的,試著將它隱藏在某個函式背後。客戶呼叫該函式即可,而不需要將轉型放進他們自己程式碼中。(隔離轉型動作)
3)寧可使用C++-style(新式)轉型,不要使用舊式轉型。

[======]

條款28:避免返回handles指向物件內部成分

Avoid returning "handles" to object internals.

為什麼要避免返回handles指向物件內部成分?
先來看一個例子。假設程式涉及矩形,每個矩形(Rectangle)由左上角、右下角表示。為了讓Rectangle物件儘可能小,可能會決定不把定義這些矩形的點存放在Rectangle物件內,而是放在一個輔助的struct內,讓再Rectangle去指它:

class Point {
public:
    Point(int x, int y);
    ...
    void setX(int newValue);
    void setY(int newValue);
    ...
};

struct RectData { // 點資料用來表現一個矩形
    Point ulhc; // upper left-hand corner 左上角
    Point urhc; // lower right-hand corner 右下角
};
class Rectangle {
    ...
private:
    shared_ptr<RectData> pData; // 智慧指標指向RectData物件
};

返回reference指向物件內部成分的缺陷

Rectangle的客戶需要計算Rectangle的(四個頂點)範圍,所以該class提供upperLeft函式、lowerRight函式。Point是使用者自定義型別,

// 可以通過編譯, 但設計是錯誤的
class Rectangle {
public:
    ...
    Point& upperLeft() const { return pData->ulhc; } // 左上頂點, by reference方式返回使用者自定義型別(根據條款20)
    Point& lowerRight() const { return pData->urhc; } // 右下頂點, by reference方式返回使用者自定義型別(根據條款20)
    ...
};

上面的設計可以通過編譯,但卻是錯誤的。因為它是自我矛盾的。一方面,upperLeft, lowerRight宣告為const函式,表明提供Rectangle座標點資訊,而不是讓客戶修改Rectangle (條款3);另一方面,2個函式都返回reference指向了private內部資料,呼叫者可以通過這些reference修改內部private資料。

Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // rec是const 矩形, 左上角(0,0) 右下角(100,100)

rec.upperLeft().setX(50); // rec現在變成了左上角(50,0)右下角(100,100)
// 也就是說, rec的private資料發生了改變, const 矩形不再是不可變的(const).

2個教訓:

1)成員變數的封裝性最多隻等於“返回其reference”的函式的訪問級別,而不再是簡單的宣告變數時的訪問級別。
比如,pData雖然是class Rectangle的private成員變數,但是class也提供了public member返回了其引用(upperLeft, lowerRight),這樣,pData的封裝性不再是private,而是public。

2)如果const成員函式傳出一個reference,後者所指資料與物件自身有關聯,而它又被儲存於物件之外,那麼這個函式的呼叫者可以修改那筆資料(reference所指資料)。
比如,const成員函式upperLeft和lowerRight各傳出一個reference,而 reference所指資料是左上、右下頂點,與Rectangle物件自身有關聯,而且儲存在Rectangle物件外的 Point物件,那麼upperLeft和lowerRight的呼叫者可以修改reference所指的資料(Point物件)了。

返回handles與返回references

reference、指標、迭代器統統都是所謂handles(控制代碼,用來取得某個物件),而返回一個“代表物件內部資料”的handle,這樣就會面臨“降低物件封裝性”的風險,同時可能導致“const成員函式卻造成物件狀態被修改”。

通常,物件的“內部”是指它的成員變數,不過,不被公開使用的成員函式(private或protected),也是物件“內部”的一部分。因此,也要留心不要返回它們的handles。如果確實這麼做了,它的實際訪問級別就會提高,因為客戶可以通過指標直接呼叫。

如何解決返回reference,被客戶修改的問題?

可以對返回型別加上const,這樣客戶可以讀取矩形的Points,但不能修改。也就意味著,客戶不能改變物件狀態。

class Rectangle {
public:
    ...
    const Point& upperLeft() const { return pData->ulhc; } // 返回const物件, 客戶無法修改
    const Point& lowerRIght() const { return pData->urhc; } // 返回const物件, 客戶無法修改
    ...
};

上述方案也不是完美的,存在空懸指標的問題。因為pData所指的物件可能會不存在,如果還使用該指標,就會產生“野指標”問題(記憶體已釋放)。
例如,boundingBox函式返回GUI物件的外框(bouding box),

// 外框採用矩形形式
// GUI物件
class GUIObject { ... }; 
// 以by value方式返回一個矩形
const Rectangle boundingBox(const GUIObject& obj); // 條款3談過為什麼返回型別是const

// 客戶可能這樣使用boundingBox函式
GUIObject* pgo;
... // 設定pgo指向的GUI物件
// 取得一個指標指向外框的左上角頂點
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft()); // 錯誤: boundingBox(*pgo)返回的是一個臨時物件, 語句結束後釋放物件, pUpperLeft成為空懸指標

boundingBox(*pgo)返回的將是一個Rectangle臨時物件,而在這行語句結束後,就會釋放該臨時物件。而pUpperLeft卻指向了其內部pData所指向的ulhc物件(Point),該物件是會隨臨時物件的析構而釋放的,因此pUpperLeft將成為空懸指標。

雖然member函式封handle可能產生風險,但有時必須這麼做,比如operator[],返回的就是物件的reference,指向內部資料。

小結

1)避免返回handle(包括reference、指標、迭代器)指向物件內部。遵守該條款可以增加封裝性,幫助const member函式的行為像const,並將“空懸指標”的可能性降到最低。

[======]

條款30:透徹瞭解inlining的裡裡外外

Understand the ins and outs of inlining.

inline函式

理念:將函式的每個呼叫,都以函式本體替換之。

inline只是對編譯器的一個申請,不是強制命令。申請分為兩種方式:隱喻提出,明確提出。

  • 隱喻提出:將函式定義於class宣告中。
  • 明確提出:在函式定義式前,加上關鍵字inline。

大部分編譯器拒絕將太過複雜(如帶有迴圈或遞迴)的函式inlining(內聯),而所有堆virtual函式的呼叫也都會使inlining落空,因為virtual意味著“等待,直到執行期才確定呼叫哪個函式”,而inline意味著“執行前,先將呼叫動作替換為被呼叫函式的本體”。
編譯器通常不對“通過函式指標進行的呼叫 ”實施inlining。

程式庫設計者必須評估“將函式宣告為inline”的衝擊:inline函式無法隨著程式庫升級而升級。也就是說,如果f是程式庫內的inline函式,客戶將函式f編程序序中,一旦程式庫設計者決定改變f,所有用到f的客戶端程式都必須重新編譯。而如果f是non-inline函式,只需要重新連結就好;如果是動態連結,升級後的函式可以直接被使用。

另外一個現實問題,大多數調速器對inline函式束手無策,因為無法為一個並不存在的函式設立斷點。

建議:一開始不要將任何函式宣告為inline,或者實行有限範圍內的函式稱為inline。待到有優化需求時,再改成inline。

小結

1)將大多數inlining限制在小型、被頻繁呼叫的函式身上。可使日後的除錯過程和二進位制升級(binary upgradability)更容易,也可使潛在的程式碼膨脹問題最小化,使程式的速度提升計劃最大化;
2)不要只因為function templates出現在標頭檔案,就將它們宣告為inline;

[======]

條款31:將檔案間的編譯依存關係降到最低

Minimize compilation dependencies between files.

問題引出

考慮C++ class:

// Person定義
/* 標頭檔案 */
#include <string>
#include "date.h"
#include "address.h"

class Person {
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
    ...
private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

Person定義檔案include了其他檔案之間形成了一種編譯依存關係(compliation dependency)。如果這些標頭檔案中有一個被改變,或者這些標頭檔案所依賴的其他標頭檔案有改變,那麼每個include Person class的檔案就得重新編譯,任何使用Person class的檔案也必須重新編譯。這樣一連串的編譯依存關係會對許多專案造成難以形容的災難。

那麼,我們如何解決這個問題呢?

方法一:Handle class

可以把Person分割為2個class:一個只提供介面(Person),另一個負責實現該介面(PersonImpl)。
持有實現介面(PersonImpl物件)指標的類Person,稱為Handle class。

// 介面那部分
#include <string>
#include <memory>

/* 前置宣告, 前提是不需要知道其定義 */
// 宣告式 (Person定義需要用到)
class PersonImpl; // Person實現類前置宣告
class Date; // Person介面用到的class的前置宣告
class Address;

// Person定義(式)
class Person { // Handle class
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name() const; // string並不是class, 而是basic_string<char>的typedef
    std::string birthDate() const;
    std::string address() const;
    // ...
private:
    // pimpl idiom (pointer to implementation)
    std::tr1::shared_ptr<PersonImpl> pImpl; // 智慧指標, 指向實現類(PersonImpl)(shared_ptr見條款13)
};

分離的關鍵在於以“宣告的依存性”替換“定義的依存性”,也就是分離 宣告所需要的東西 跟 定義所需要的東西。這也是編譯依存性最小化本質。現實中,讓標頭檔案儘可能自我滿足,萬一做不到,則讓它與其他檔案內的宣告式(class XXX;)而非定義式(class XXX{ };)相依。其他每件事源於這個簡單的設計策略:

  • 如果使用object references或object pointers可以完成任務,就不要使用objects
    因為如果定義某個型別的object,就要用到該型別的定義式。而reference和pointer只需要該型別的宣告式即可。

  • 如果能夠,儘量以class宣告式替換class定義式
    也就是說,如果只是宣告,可以不需要class定義式,用class宣告式即可。而定義裡面,能用class宣告式則儘量用。

  • 為宣告式和定義式提供不同的標頭檔案
    如,宣告式放到Person.h,實現式放到PersonImpl.h。

方法二:Interface class

令Person稱為一種特殊的abstract base class(抽象基類),稱為Interface class。類似於Java裡面的interface,專門提供derived class的介面,因此不帶成員變數,也沒有建構函式,只有一個virtual解構函式 + 一組pure virtual函式(純虛擬函式),用來描述整個介面。不過,C++並不禁止interface內實現成員變數或成員函式。

// interface class 
// 沒有建構函式
class Person {
public:
    virtual ~Person(); // virtual解構函式
    virtual std::string name() const = 0; // pure virtual函式
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;
    // ...
    // factory函式, 建立新物件
    static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};

思考:為什麼要有factory函式?
答:因為Interface class的客戶必須有辦法為這種class建立新物件,他們通常呼叫一個特殊函式,此函式扮演那個derived class的建構函式的角色。客戶持有的,通常是Interface class Person類指標,而非derived class,否則程式的設計不具有複用性。
如果有了factory函式(create),客戶可以這樣使用:

string name;
Date dateOfBirth;
Address address;
...
// 建立一個物件, 支援Person介面
shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
cout << pp->name()
     << " was born on "
     << " and now lives at "
     << pp->address();
...

客戶真正使用的,是實現Interface class介面的那個具象類(concrete class),因此該類必須被定義出來,而且建構函式必須被呼叫。
注意:interface class並沒有建構函式。

具象類實現:

// 實現interface class的具象類
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;
};

// 一個具體的factory函式實現
shared_ptr<Person> Person::create(const string&name, const Date& birthday, const Address& addr)
{
    return shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

Handle class與interface class

在handle class的方案中,handle class和實現類是依賴關係(use-a):handle class依賴於實現類,持有指向實現類指標;
在interface class的方案中,interface class和實現類是實現關係:實現類實現了interface class的介面。

什麼時候考慮使用handle class和interface class?
當想要實現程式碼變化時,對客戶帶來最小衝擊,就應當考慮使用handle class和interface class解耦宣告與實現,以使編譯依存性最小。

小結

1)支援“編譯依存性最小化”的一般構想:相依於宣告,不要相依於定義式。基於此構想的2個手段是Handle class和interface class。
2)程式庫標頭檔案應該以“完全且僅有宣告式”(full and declaration-only forms)的形式存在。這種做法不論設計template都適用。

[======]