C++編程思想重點筆記(下)

上篇請看:C++編程思想重點筆記(上)

  1. 宏的好處與壞處

    • 宏的好處:#與##的使用

      三個有用的特徵:字符串定義、字符串串聯和標誌粘貼。

      字符串定義的完成是用#指示,它容許設一個標識符並把它轉化爲字符串,然而字符串串聯發生在當兩個相鄰的字符串沒有分隔符時,在這種情況下字符串組合在一起。在寫調試代碼時,這兩個特徵是非常有效的。

      1. #define DEBUG(X) cout<<#X " = " << X << endl

      上面的這個定義可以打印任何變量的值。 
      我們也可以得到一個跟蹤信息,在此信息裏打印出它們執行的語句。

      1. #define TRACE(S) cout << #S << endl; S

      #S定義了要輸出的語句。第2個S重申了語句,所以這個語句被執行。當然,這可能會產生問題,尤其是在一行for循環中。

      1. for (int i = 0 ; i < 100 ; i++ )
      2. TRACE(f(i)) ;

      因爲在TRACE( )宏裏實際上有兩個語句,所以一行for循環只執行第一個。

      1. for (int i = 0 ; i < 100 ; i++ )
      2. cout << "f(i)" << endl;
      3. f(i); // 第二條語句脫離了for循環,因此執行不到

      解決方法是在宏中用逗號代替分號

      標誌粘貼在寫代碼時是非常有用的,用##表示。它讓我們設兩個標識符並把它們粘貼在一起自動產生一個新的標識符。例如:

      1. #define FIELD(A) char *A##_string;int A##_size

      此時下面的代碼:

      1. class record{
      2. FIELD(one);
      3. FIELD(two);
      4. FIELD(three);
      5. //...
      6. };

      就相當於下面的代碼:

      1. class record{
      2. char *one_string,int one_size;
      3. char *two_string,int two_size;
      4. char *three_string,int three_size;
      5. //...
      6. };
    • 宏的不好:容易出錯 
      下面舉個例子即可說明:

      1. #define band(x) (((x)>5 && (x)<10) ? (x) :0)
      2. int main() {
      3. for(int i = 4; i < 11; i++) {
      4. int a = i;
      5. cout << "a = " << a << "\t";
      6. cout << "band(++a)" << band(++a) << "\t";
      7. cout << "a = " << a << endl;
      8. }
      9. return 0;
      10. }

      輸出:

      a = 4 band(++a)0 a = 5 
      a = 5 band(++a)8 a = 8 
      a = 6 band(++a)9 a = 9 
      a = 7 band(++a)10 a = 10 
      a = 8 band(++a)0 a = 10 
      a = 9 band(++a)0 a = 11 
      a = 10 band(++a)0 a = 12

  2. 存儲類型指定符 
    常用的有staticextern。 
    不常用的有兩個:一是auto,人們幾乎不用它,因爲它告訴編譯器這是一個局部變量,實際上編譯器總是可以從 變量定義時的上下文中判斷出這是一個局部變量。所以auto是多餘的。還有一個是register,它也是局部變量,但它告訴編譯器這個特殊的變量要經常用到,所以編譯器應該儘可能地讓它保存在寄存器中。它用於優化代碼。各種編譯器對這種類型的變量處理方式也不盡相同,它們有時會忽略這種存儲類型的指定。一般,如果要用到這個變量的地址, register指定符通常都會被忽略。應該避免用register類型,因爲編譯器在優化代碼方面通常比我們做得更好。

  3. 位拷貝(bitcopy)與值拷貝的區別(很重要) 
    由1個例子來說明:一個類在任何時候知道它存在多少個對象,可以通過包含一個static成員來做到,如下代碼所示:

    1. #include <iostream>
    2. using namespace std;
    3. class test {
    4. static int object_count;
    5. public:
    6. test() {
    7. object_count++;
    8. print("test()");
    9. }
    10. static void print(const char *msg = 0) {
    11. if(msg) cout << msg << ": ";
    12. cout << "object_count = " << object_count << endl;
    13. }
    14. ~test() {
    15. object_count--;
    16. print("~test()");
    17. }
    18. };
    19. int test::object_count = 0;
    20. // pass and return by value.
    21. test f(test x) {
    22. x.print("x argument inside f()");
    23. return x;
    24. }
    25. int main() {
    26. test h;
    27. test::print("after construction of h");
    28. test h2 = f(h);
    29. test::print("after call to f()");
    30. return 0;
    31. }

    然而輸出並不是我們期望的那樣:

    test(): object_count = 1 
    after construction of h: object_count = 1 
    x argument inside f(): object_count = 1 
    ~test(): object_count = 0 
    after call to f(): object_count = 0 
    ~test(): object_count = -1 
    ~test(): object_count = -2

    在h生成以後,對象數是1,這是對的。我們希望在f()調用後對象數是2,因爲h2也在範圍內。然而,對象數是0,這意味着發生了嚴重的錯誤。這從結尾兩個析構函數執行後使得對象數變爲負數的事實得到確認,有些事根本就不應該發生。

    讓我們來看一下函數f()通過傳值方式傳入參數那一處。原來的對象h存在於函數框架之外,同時在函數體內又增加了一個對象,這個對象是傳值方式傳入的對象的拷貝,這屬於位拷貝,調用的是默認拷貝構造函數,而不是調用構造函數。然而,參數的傳遞是使用C的原始的位拷貝的概念,但test類需要真正的初始化來維護它的完整性。所以,缺省的位拷貝不能達到預期的效果。

    當局部對象出了調用的函數f()範圍時,析構函數就被調用,析構函數使object_count減小。 所以,在函數外面, object_count等於0。h2對象的創建也是用位拷貝產生的(也是調用默認拷貝構造函數),所以,構造函數在這裏也沒有調用。當對象h和h2出了它們的作用範圍時,它們的析構函數又使object_count值變爲負值。

    總結: 

    • 位拷貝拷貝的是地址(也叫淺拷貝),而值拷貝則拷貝的是內容(深拷貝)。
    • 深拷貝和淺拷貝可以簡單理解爲:如果一個類擁有資源,當這個類的對象發生複製過程的時候,資源重新分配,這個過程就是深拷貝,反之,沒有重新分配資源,就是淺拷貝。
    • 默認的拷貝構造函數”和“缺省的賦值函數”均採用“位拷貝”而非“值拷貝”的方式來實現,倘若類中含有指針變量,這兩個函數註定將出錯。

    關於位拷貝和值拷貝的深入理解可以參考這篇文章:C++中的位拷貝與值拷貝淺談

  4. 爲了達到我們期望的效果,我們必須自己定義拷貝構造函數:
    1. test(const test& t) {
    2. object_count++;
    3. print("test(const test&)");
    4. }

    這樣輸出才正確:

    test(): object_count = 1 
    after construction of h: object_count = 1 
    test(const test&): object_count = 2 
    x argument inside f(): object_count = 2 
    test(const test&): object_count = 3 
    ~test(): object_count = 2 
    after call to f(): object_count = 2 
    ~test(): object_count = 1 
    ~test(): object_count = 0


    引申

  • 如果在main中加一句“f(h);”,即忽略返回值,那麼返回的時候還會調用拷貝構造函數嗎? 
    答案是:會調用。這時候會產生一個臨時對象,由於該臨時對象沒有用處,因此會馬上調用析構函數銷燬掉。這時候輸出就會像下面這樣:

    test(): object_count = 1 
    after construction of h: object_count = 1 
    test(const test&): object_count = 2 
    x argument inside f(): object_count = 2 
    test(const test&): object_count = 3 
    ~test(): object_count = 2 
    after call to f(): object_count = 2 
    test(const test&): object_count = 3 
    x argument inside f(): object_count = 3 
    test(const test&): object_count = 4 
    ~test(): object_count = 3 
    ~test(): object_count = 2 
    ~test(): object_count = 1 
    ~test(): object_count = 0

  • 如果一個類由其它幾個類的對象組合而成,如果此時該類沒有自定義拷貝構造函數,那麼編譯器遞歸地爲所有的成員對象和基本類調用拷貝構造函數。如果成員對象也含有別的對象,那麼後者的拷貝構造函數也將被調用。

  • 怎樣避免調用拷貝構造函數?僅當準備用傳值的方式傳遞類對象時,才需要拷貝構造函數。有兩種解決方法:

    • 防止傳值方法傳遞 
      有一個簡單的技術防止通過傳值方式傳遞:聲明一個私有private拷貝構造函數。我們甚至不必去定義它,除非我們的成員函數或友元函數需要執行傳值方式的傳遞。如果用戶試圖用傳值方式傳遞或返回對象,編譯器將會發出一個出錯信息。這是因爲拷貝構造函數是私有的。因爲我們已顯式地聲明我們接管了這項工作,所以編譯器不再創建缺省的拷貝構造函數。

      1. class noCC {
      2. int i;
      3. noCC(const noCC&); // private and no definition
      4. public:
      5. noCC(int I = 0) : i(I) {}
      6. };
      7. void f(noCC);
      8. main() {
      9. noCC n;
      10. //! f(n); // error: copy-constructor called
      11. //! noCC n2 = n; // error: c-c called
      12. //! noCC n3(n); // error: c-c called
      13. }

      注意這裏n2 = n也調用拷貝構造函數,注意這裏要和賦值函數區分。

    • 改變外部對象的函數 
      使用引用傳遞:比如void get(const Slice&);
  • 非自動繼承的函數 
    構造函數、析構函數和賦值函數(operator=)不能被繼承。

  • 私有繼承的目的 
    private繼承的目的是什麼,因爲在類中選擇創建一個private對象似乎更合適。將private繼承包含在該語言中只是爲了語言的完整性。但是,如果沒有其他理由,則應當減少混淆,所以通常建議用private成員而不是private繼承。 
    然而,private繼承也不是一無用處。 
    這裏可能偶然有這種情況,即可能想產生像基類接口一樣的接口,而不允許處理該對象像處理基類對象一樣。private繼承提供了這個功能


    引申

    能對私有繼承成員公有化嗎? 
    當私有繼承時,基類的所有public成員都變成了private。如果希望它們中的任何一個是可視的,可以辦到嗎?答案是可以的,只要用派生類的public選項聲明它們的名字即可(新的標準中使用using關鍵字)。

    1. #include <iostream>
    2. class base {
    3. public:
    4. char f() const { return 'a'; }
    5. int g() const { return 2; }
    6. float h() const { return 3.0; }
    7. };
    8. class derived : base {
    9. public:
    10. using base::f; // Name publicizes member
    11. using base::h;
    12. };
    13. int main() {
    14. derived d;
    15. d.f();
    16. d.h();
    17. // d.g(); // error -- private function
    18. return 0;
    19. }

    這樣,如果想要隱藏這個類的基類部分的功能,則private繼承是有用的

  • 多重繼承注意向上映射的二義性。比如base(有個f()方法)有兩個子對象d1和d2,且都重寫了base的f()方法,此時子類dd如果也有f()方法則不能同時繼承自d1和d2,因爲f()方法存在二義性,不知道該繼承哪個f()方法。 
    解決方法是對dd類中的f()方法重新定義以消除二義性,比如明確指定使用d1的f()方法。 
    當然也不能將dd類向上映射爲base類,這可以通過使用虛繼承解決,關鍵字virtual,base中的f()方法改成虛函數且d1和d2的繼承都改爲虛繼承,當然dd繼承d1和d2用public繼承即可。

  • C語言中如何關閉assert斷言功能? 
    頭文件:<assert.h>或<cassert> 
    在開發過程中,使用它們,完成後用#define NDEBUG使之失效,以便推出產品,注意必須在頭文件之前關閉纔有效。

    1. #define NDEBUG
    2. #include <cassert>
  • C++如何實現動態捆綁?—即多態的實現(很重要) 
    C++中爲了實現多態,編譯器對每個包含虛函數的類創建一個表(稱爲VTABLE,虛表)。在 VTABLE中,編譯器放置特定類的虛函數地址。在每個帶有虛函數的類中,編譯器祕密地置一指針,稱爲vpointer(縮寫爲VPTR),指向這個對象的VTABLE。通過基類指針做虛函數調用時(也就是做多態調用時),編譯器靜態地插入取得這個VPTR,並在VTABLE表中查找函數地址的代碼,這樣就能調用正確的函數使晚捆綁發生。 
    爲每個類設置VTABLE、初始化VPTR、爲虛函數調用插入代碼,所有這些都是自動發生的,所以我們不必擔心這些。利用虛函數,這個對象的合適的函數就能被調用,哪怕在編譯器還不知道這個對象的特定類型的情況下。

    在vtable表中,編譯器放置了在這個類中或在它的基類中所有已聲明爲virtual的函數的地址。如果在這個派生類中沒有對在基類中聲明爲virtual的函數進行重新定義,編譯器就使用基類的這個虛函數地址。 
    下面舉個例子說明:

    1. #include <iostream>
    2. enum note { middleC, Csharp, Cflat };
    3. class instrument {
    4. public:
    5. virtual void play(note) const {
    6. cout << "instrument::play" << endl;
    7. }
    8. virtual char* what() const {
    9. return "instrument";
    10. }
    11. // assume this will modify the object:
    12. virtual void adjust(int) {}
    13. };
    14. class wind : public instrument {
    15. public:
    16. void play(note) const {
    17. cout << "wind::play" << endl;
    18. }
    19. char* what() const {
    20. return "wind";
    21. }
    22. void adjust(int) {}
    23. };
    24. class percussion : public instrument {
    25. public:
    26. void play(note) const {
    27. cout << "percussion::play" << endl;
    28. }
    29. char* what() const {
    30. return "percussion";
    31. }
    32. void adjust(int) {}
    33. };
    34. class string : public instrument {
    35. public:
    36. void play(note) const {
    37. cout << "string::play" << endl;
    38. }
    39. char* what() const {
    40. return "string";
    41. }
    42. void adjust(int) {}
    43. };
    44. class brass : public wind {
    45. public:
    46. void play(note) const {
    47. cout << "brass::play" << endl;
    48. }
    49. char* what() const {
    50. return "brass";
    51. }
    52. };
    53. class woodwind : public wind {
    54. public:
    55. void play(note) const {
    56. cout << "woodwind::play" << endl;
    57. }
    58. char* what() const {
    59. return "woodwind";
    60. }
    61. };
    62. instrument *A[] = {
    63. new wind,
    64. new percussion,
    65. new string,
    66. new brass
    67. };

    下圖畫的是指針數組A[]。

    下面看到的是通過instrument指針對於brass調用adjust()。instrument引用產生如下結果:

    編譯器從這個instrument指針開始,這個指針指向這個對象的起始地址。所有的instrument對象或由instrument派生的對象都有它們的VPTR,它在對象的相同的位置(常常在對象的開頭),所以編譯器能夠取出這個對象的VPTR。VPTR指向VTABLE的開始地址。所有的VTABLE有相同的順序,不管何種類型的對象。 play()是第一個,what()是第二個,adjust()是第三個。所以編譯器知道adjust()函數必在VPTR + 2處。這樣,不是“以instrument :: adjust地址調用這個函數”(這是早捆綁,是錯誤活動),而是產生代碼,“在VPTR + 2處調用這個函數”。因爲VPTR的效果和實際函數地址的確定發生在運行時,所以這樣就得到了所希望的晚捆綁。向這個對象發送消息,這個對象能斷定它應當做什麼。


    引申 — 對象切片

    當多態地處理對象時,傳地址與傳值有明顯的不同。所有在這裏已經看到的例子和將會看到的例子都是傳地址的,而不是傳值的。這是因爲地址都有相同的長度,傳派生類型(它通常稍大一些)對象的地址和傳基類(它通常小一點)對象的地址是相同的。如前面解釋的,使用多態的目的是讓對基類對象操作的代碼也能操作派生類對象。 
    如果使用對象而不是使用地址或引用進行向上映射,發生的事情會使我們喫驚:這個對象 被“切片”,直到所剩下來的是適合於目的的子對象。在下面例子中可以看到通過檢查這個對象的長度切片剩下來的部分。

    1. #include <iostream>
    2. using namespace std;
    3. class base {
    4. int i;
    5. public:
    6. base(int I = 0) : i(I) {}
    7. virtual int sum() const { return i; }
    8. };
    9. class derived : public base {
    10. int j;
    11. public:
    12. derived(int I = 0, int J = 0) : base(I), j(J) {}
    13. virtual int sum() const { return base::sum() + j; }
    14. };
    15. void call(base b) {
    16. cout << "sum = " << b.sum() << endl;
    17. }
    18. main() {
    19. base b(10);
    20. derived d(10, 47);
    21. call(b);
    22. call(d);
    23. }

    函數call( )通過傳值傳遞一個類型爲base的對象。然後對於這base對象調用虛函數sum( )。 我們可能希望第一次調用產生10,第二次調用產生57。實際上,兩次都產生10。 在這個程序中,有兩件事情發生了

    • 第一,call( )接受的只是一個base對象,所以所有在這個函數體內的代碼都將只操作與base相關的數。 對call( )的任何調用都將引起一個與base大小相同的對象壓棧並在調用後清除。這意味着,如果一個由base派生來類對象被傳給call,編譯器接受它,但只拷貝這個對象對應於base的部分,切除這個對象的派生部分,如圖: 

      現在,我們可能對這個虛函數調用感到奇怪:這裏,這個虛函數既使用了base(它仍存在), 又使用了derived的部分(derived不再存在了,因爲它被切片)。 其實我們已經從災難中被解救出來,這個對象正安全地以值傳遞。因爲這時編譯器認爲它知道這個對象的確切的類型(這個對象的額外特徵有用的任何信息都已經失去)。
    • 另外,用值傳遞時,它對base對象使用拷貝構造函數,該構造函數初始化vptr指向base vtable,並且只拷貝這個對象的base部分。這裏沒有顯式的拷貝構造函數,所以編譯器自動地爲我們合成一個。由於上述諸原因,這個對象在切片期間變成了一個base對象。

    對象切片實際上是去掉了對象的一部分,而不是象使用指針或引用那樣簡單地改變地址的內容。因此,對象向上映射不常做,事實上,通常要提防或防止這種操作。我們可以通過在基 類中放置純虛函數來防止對象切片。這時如果進行對象切片就將引起編譯時的出錯信息。

    最後注意:虛機制在構造函數中不工作。即在構造函數中調用虛函數沒有結果。

  • RTTI—運行時類型識別(很重要)

    • 概念

      運行時類型識別(Run-time type identification, RTTI)是在我們只有一個指向基類的指針或引用時確定一個對象的準確類型

    • 使用方法 
      一般情況下,我們並不需要知道一個類的確切類型,虛函數機制可以實現那種類型的正確行爲。但是有些時候,我們有指向某個對象的基類指針,確定該對象的準確類型是很有用的。 
      RTTI與異常一樣,依賴駐留在虛函數表中的類型信息。如果試圖在一個沒有虛函數的類上用RTTI,就得不到預期的結果。

      RTTI的兩種使用方法

      • 第一種使用typeid(),就像sizeof()一樣,看上都像一個函數。但實際上它是由編譯器實現的。typeid()帶有一個參數,它可以是一個對象引用或指針,返回全局typeinfo類的常量對象的一個引用。可以用運算符“==”和“!=”來互相比較這些對象。也可以用name()來獲得類型的名稱。注意,如果給typeid( )傳遞一個shape*型參數,它會認爲類型爲shape*,所以如果想知道一個指針所指對象的精確類型,我們必須逆向引用這個指針。比如,s是個shape*, 那麼:

        1. cout << typeid(*s).name()<<endl;

        將顯示出s所指向的對象類型。 
        也可以用before(typeinfo&)查詢一個typeinfo對象是否在另一個typeinfo對象的前面(以定義實現的排列順序),它將返回true或false。如果寫:

        1. if(typeid(me).before(typeid(you))) //...

        那麼表示我們正在查詢me在排列順序中是否在you之前。

      • RTTI的第二個用法叫“安全類型向下映射”。使用dynamic_cast<>模板。

      兩種方法的使用舉例如下:

      1. #include <iostream>
      2. #include <typeinfo>
      3. using namespace std;
      4. class base {
      5. int i;
      6. public:
      7. base(int I = 0) : i(I) {}
      8. virtual int sum() const { return i; }
      9. };
      10. class derived : public base {
      11. int j;
      12. public:
      13. derived(int I = 0, int J = 0) : base(I), j(J) {}
      14. virtual int sum() const { return base::sum() + j; }
      15. };
      16. main() {
      17. base *b = new derived(10, 47);
      18. // rtti method1
      19. cout << typeid(b).name() << endl; // P4base
      20. cout << typeid(*b).name() << endl; // 7derived
      21. if(typeid(b).before(typeid(*b)))
      22. cout << "b is before *b" << endl;
      23. else
      24. cout << "*b is before b" << endl;
      25. // rtti method2
      26. derived *d = dynamic_cast<derived*>(d);
      27. if(d) cout << "cast successful" << endl;
      28. }

      注意1:這裏如果沒有多態機制,則RTTI可能運行的結果不是我們想要的,比如如果沒有虛函數,則這裏兩個都顯示base,一般希望RTTI用於多態類。 
      注意2:運行時類型的識別對一個void型指針不起作用。void *確實意味着“根本沒有類型信息”。

      1. void *v = new stimpy;
      2. stimpy* s = dynamic_cast<stimpy*>(v); // error
      3. cout << typeid(*v).name() << endl; // error
    • RTTI的實現 
      **典型的RTTI是通過在VTABLE中放一個額外的指針來實現的。這個指針指向一個描述該特定類型的typeinfo結構(每個新類只產生一個typeinfo的實例),所以typeid( )表達式的作用實際上很簡單。**VPTR用來取typeinfo的指針,然後產生一個結果typeinfo結構的一個引用—這是一個決定性的步驟—我們已經知道它要花多少時間。

      對於dynamic_cast<目標* > <源指針>,多數情況下是很容易的,先恢復源指針的RTTI信息再取出目標*的類型RTTI信息,然後調用庫中的一個例程判斷源指針是否與目標*相同或者是目標*類型的基類。它可能對返回的指針做了一點小的改動,因爲目的指針類可能存在多重繼承的情況,而源指針類型並不是派生類的第一個基類。在多重繼承時情況會變得複雜些,因爲一個基類在繼承層次中可能出現一次以上,並且可能有虛基類。 
      用於動態映射的庫例程必須檢查一串長長的基類列表,所以動態映射的開銷比typeid()要大(當然我們得到的信息也不同,這對於我們的問題來說可能很關鍵),並且這是非確定性的,因爲查找一個基類要比查找一個派生類花更多的時間。另外動態映射允許我們比較任何類型,不限於在同一個繼承層次中比較兩個類。這使得動態映射調用的庫例程開銷更高了。

      映射類型含義
      static_cast爲了“行爲良好”和“行爲較好”而使用的映射,包括一些我們可能現在不用的映射(如向上映射和自動類型轉換)
      const_cast用於映射常量和變量(const和volatile)
      const_cast爲了安全類型的向下映射(本章前面已經介紹)
      reinterpret_cast爲了映射到一個完全不同的意思。這個關鍵詞在我們需要把類型映射回原有類型時要用到它。我們映射到的類型僅僅是爲了故弄玄虛和其他目的。這是所有映射中最危險的

      注意:如果想把一個const 轉換爲非const,或把一個volatile轉換成一個非volatile(勿遺忘這種情況),就要用到const_cast。這是可以用const_cast的唯一轉換。如果還有其他的轉換牽涉進來,它必須分開來指定,否則會有一個編譯錯誤。

  • 發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章