大多數情況下,適當地提出你的class和class template定義以及function和function template聲明是花費最多心力的兩件事情。一旦正確地完成他們,相當的實現就簡單的多了,但實際上還存在少量問題值得注意。
不要太早地聲明變量,因為你可能永遠也用不到這個變量。
void test(std::string& password) {
std::string encrypted;
if (password.length() < MinimumPasswordLength) {
throw logic_error("password is too short");
}
...
encrypted變量在函數拋出異常時,還沒有被使用就已經失效了。雖然一個變量對象所占的內存並不會太多,但這是一種注重效率的思維方式,仍然值得提及。
合理的做法應該等到需要使用encrypted時才聲明它。
void test(std::string& password) {
if (password.length() < MinimumPasswordLength) {
throw logic_error("password is too short");
}
std::string encrypted;
...
初始化一個對象很多種方法,但不同的方法效率是不一樣的。我們總是願意使用效率更高的方法,例如初始化一個對象,不要先使用缺省構造函數,然後再賦值,而是直接使用復制構造函數直接初始化對象,可以節省很多地開銷。
在循環中使用對象,此時應該如何聲明對象?存在兩種方法,一種是在循環內聲明對象,另一種是在循環外聲明對象。這兩種方法的效率問題有值得討論的地方。
兩種方法的成本為:
1) n個構造函數和n個析構函數 2)1個構造函數+1個析構函數+n個賦值操作如果class的一個賦值成本低於一組構造+析構成本,那麼使用方法2總是更有效的,否則方法1更好。但是方法2變量的作用域更大,有時對程序的可理解性和易維護性造成沖擊。因而除非1)你知道賦值成本比構造+析構成本低,2)你正在處理代碼中效率高度敏感的部分,否則應該使用做法1。
C++規則的設計目標之一是,保證“類型錯誤”絕不可能發生。理論上如果你的程序“很干淨地”通過編譯,就表示它並不會企圖在任何對象上執行任何不安全、無意義、愚蠢荒謬的操作。這還是一個極具價值的保證!
然而,不幸的是,轉型破壞了類型系統,那可能會導致一些麻煩,有些容易辨識,有些非常隱晦。本節討論如何減少使用轉型操作來使代碼更有合理、高效。
關於四種轉型方式的區別詳情見前文類型轉換
許多程序員相信,轉型其實什麼都沒做,只是告訴編譯器把某種類型視為另一種類型。但這是錯誤的觀念。任何一種類型轉換的(不論是顯示的還是隱式的)往往真的令編譯器編譯出運行期間執行的碼。就算是把int轉型為double,因為在內存中int的存儲方式和double的存儲方式是截然不同的。
又例如我們可能會讓一個基類指針指向一個子類:
class base { ... };
class derived : public base { ... };
derived d;
base* pb = &d;
在這裡我們不過是建立了一個base class指針指向一個derived class對象,但有時候上述兩個指針值並不相同。這種情況下會有個偏移量(offset)在運行期被執行於dereived*指針上,用以取得正確的base*指針值。所以單一對象可能擁有一個以上的地址,這就意味著你總是需要避免做出基於”對象在C++中如何布局“的假設來執行相應的轉型操作,因為不同編譯器對於不同對象布局方式和他們的地址計算方式是不同的。
另一個似是而非的代碼出現在我們希望在子類函數中調用基類函數,如:
class Window {
public:
virtual void onResize() { ... }
...
};
class SpeacialWindow {
public:
virtual void onResize() {
static_cast(*this).onResize(); // 企圖調用基類相關函數。
...
}
...
};
代碼中強調了了轉型動作,把this指針暫時轉型為基類指針。當然沒問題,的確調用了基類的函數,但問題仍然存在。實際上調用的並不是當前對象上的函數,而是稍早轉型動作所建立的一個”*this對象之base class成分“的暫時副本身上的onResize!如果這個函數不對數據成員起影響當然就沒有問題,但如果會改變數據成員就會出現很大的問題。
解決方法是去掉轉型操作:
class SpeacialWindow {
public:
virtual void onResize() {
Window::onResize(); // 通過作用符來強制調用基類的函數。
...
}
...
};
class Window { ... };
class SpecialWindow : public Window {
public:
void blink();
...
};
typedef std::vector > VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrys.end(); iter++) {
if (SpecialWindow* psw = dynamic_cast(iter->get()))
psw->blink();
}
之所以需要dynamic_cast,通常是因為你想在一個你認定為derived class對象身上執行dereived class操作函數,但你只有一個紙箱base的pointer/reference。但實際上,還是存在方法避免這個問題的。
在介紹解決方法之前,我們還需要強調,使用dynamic_cast是非常慢的!特別是在多種繼承和深度繼承時。因為他需要不斷地尋找合適的class。
class Window { ... };
class SpecialWindow : public Window {
public:
void blink();
...
};
typedef std::vector > VPSW;
VPSW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrys.end(); iter++) {
(*iter)->blink();
}
這裡直接在容器內存放子類的指針。
但其實上,你不可能期望能在同一個容日內存儲指針”指向所有可能之各種Window派生類“。所以更加合理的做法實際上是運用多態,實現動態綁定。
class Window {
public:
virtual void blink() { }
...
};
class SpecialWindow : public Window {
public:
virtual void blink();
...
};
typedef std::vector > VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrys.end(); iter++) {
(*iter)->blink();
}
絕對要避免的就是使用一連串的dynamic_cast!因為這樣會造成代碼運行得很慢。如果要實現從基類指針到子類指針的轉變還是應該使用virtual函數,實現多態。
另外值得注意的是,如果轉型是必要的,試著把它隱藏在某個函數背後。客戶隨後可以調用該函數,而不需要將轉型放進他們的代碼內。
reference、pointer、iterator統統都是所謂的handles,而返回一個“代表對象內部數據”的handle,隨之而來的就是“降低對象封裝性”的風險。
假設我需要做一個表示矩型的類:
class Point {
public:
Point(int x, int y);
...
void setY(int newY);
void setX(int newX);
...
};
struct RectData {
Point ulhc; // upper left-hand corner;
Point lrhc; // lower right-hand corner;
};
class Rectangle {
...
// 返回reference比返回value效率更高,避免了復制構造,但也出現了問題。
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
private:
std:shared_ptr pData;
};
這樣的編譯雖然沒有問題,但是實際上卻是錯誤,因為他們隱含危機。雖然upperLeft和lowerRight被聲明為const成員函數,因為他們的目的只是為了提供客戶一個得知Rectangle相關坐標點的方法,但不讓客戶修改rectangle,但另一方面卻又返回reference指向private內部數據,調用者甚至可以通過這些reference更改內部數據!
這立刻帶給我們兩個教訓。第一,成員變量的封裝性最多只等於“返回其reference”的函數的訪問級別(就像本例中的private成員變量實際上變成了public,因為通過reference函數修改)。第二,如果const成員函數返回一個reference,後者所指數據與對象自身有關聯,而他們又被存儲於對象之外,那麼這個函數的調用者可以修改那筆數據。
解決方法其實很簡單,一種方法是不傳遞reference,但這就意味著降低效率,另一種方法是把reference變成const。
class Rectangle {
...
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
private:
std:shared_ptr pData;
};
即使有了上述的敘述,問題仍然有可能存在。更准確的說,就是它可能導致dangling handles(空懸的handle),這種handles所指的東西(的所屬對象)不復存在。這種“不復存在的對象”最常見的來源就是函數返回值。例如:
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);
...
// in main..
GUIObject* pgo; // 隨後會讓pgo指向某個GUIObject
...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft()); // error occurs!
對boundingBox的調用將獲得一個新的、暫時的、rectangle對象。對個對象是個臨時對象temp。隨後upperLeft作用於temp身上,返回一個reference指向temp的一個內部成分,就是Point。但問題開始出現了。因為在upperLeft語句結束之後,boundingBox的返回值,也就是temp,將被銷毀,而那間接導致temp內的Point析構,最後導致pUpperLeft指向一個不再存在的對象。所以pUpperLeft就變成空懸的了。
問題的關鍵在於,有個handles被傳出去了,一旦如此你就是暴露在“handle比其所指對象更長壽”的風險!
問題的解決方法就是不返回handle,就算這會導致一些效率地降低,但他總是能及時的被復制在另一個臨時變量中,然後被成功傳值,而不會出現上述導致空懸handle的問題。
當然這也並不意味著你不能讓成員函數返回handle,在某些容器中就允許返回reference。例如operator[]允許返回容器內部數據。盡管如此,這也只是例外,不是常態。
所以,請記住:
避免返回handle指向對象內部。這樣你就可以增加封裝性,幫助const成員函數的行為像個const,並將發生“dangling handle”的可能性降到最低。調用inline函數可以無需蒙受函數調用所招致的額外開銷,如此有用的功能卻也有許多東西值得注意。
編譯器最優化機制通常被設計用來濃縮那些“不含函數調用”的代碼,所以當你inline某個函數,或許編譯器就因此有能力對它執行語境相關最優化。而大部分編譯器都不會對“outlined函數調用”動作執行如此之最優化。
然而,inline背後的整體觀念是,將“對此函數的每一個調用”都以函數本體替換之。這樣做可能增加你的目標碼大小,並導致效率損失。但如果函數本體很小,編譯器針對“函數本體”所產出的碼可能比針對“函數調用”所產出的碼更小,那麼效率反而會增加。
記住,inline只是對編譯器的一個申請,不是強制命令。這項申請可以隱喻提出,也可以明確提出。隱喻提出是將函數定義在class定義式內(friend函數也可被定義於class內,那麼他們也是隱喻聲明為inline)。明確提出,是在函數前面加關鍵字inline。
inline函數通常一定被置於頭文件內,因為大多數建置環境(build environments)在編譯過程中進行inlining,而為了將一個函數調用替換為被調用函數的本體,編譯器必須知道那個函數長什麼樣。
template通常也被置於頭文件內,因為它一旦被使用,編譯器為了將它具體化,需要知道它長什麼樣。
但是template得具體化和inlining無關。如果你認為你所寫的template,所有根據此template具體出來的函數都應該inline,就將此函數聲明為inline,否則,不要這樣這麼做。
大部分編譯器拒絕太過復雜(帶有循環或遞歸)的函數inlining,而所有對virtual函數的調用inline都會落空。因為virtual意味著“等待”,在程序運行時才會決定哪個virtual函數被調用,而inline卻意味著在編譯期間就先將調用動作替換為被調用函數的本體。
有時候雖然編譯器願意inlining某個函數,但還是有可能為該函數生成一個函數本體。例如程序要取某個inline函數的地址,編譯器通常必須為此函數生成一個outlined函數本體。畢竟編譯器哪有能力提出一個指針指向並不存在的函數呢?
inline void f() { ... }
void (*pf)() = f;
...
f();
pf(); // 這個調用或許不被inlined,因為它通過函數指針完成。
在我們決定哪些函數被聲明為inline而哪些函數不應該時,我們應該掌握一個合乎邏輯的策略。一開始先不要將任何函數聲明為inline,或至少將inline施行范圍局限在哪些“一定稱為inline”或“十分平淡無奇”的函數身上。慎重使用inline便是對日後使用調試器帶來幫助,不過這麼一來也等於把自己推向手工最優化之路。
不要忘記80-20經驗法則:平均而言,一個程序往往將80%的執行時間花費在20%的代碼上。這是一個重要的法則,它提醒著我們,作為一個軟件開發者,我們的目標是找出可以有效增進程序整體效率的20%代碼,然後將它inline或竭盡所能地將它瘦身。