當前位置: 妍妍網 > 碼農

C++ 裏的「陣列」

2024-03-21碼農

者 | 吳詠煒

出品 | 程式人生(ID:coder_life)

C 陣列的問題

C 裏面就有陣列。但是,C 陣列具有很多缺陷,使用 中有很多的陷阱。我們先來看一下 其中的幾個問題。

問題一:傳參退化問題

你可以一眼看出下面程式碼的問題嗎?

#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))voidTest(int a[8]){cout << ARRAY_LEN(a) << endl;}

如果函式 Test 被呼叫的話,它的輸出結果一般不是 8,而是 2。C 的老手一定能看出問題所在,但新手很容易就迷糊了。

幸運的是,編譯器現在一般能直接對這個問題進行告警。你應該會見到類似下面這樣的告警資訊:

warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int *’ [-Wsizeof-array-argument]

cout << ARRAY_LEN(a) << endl;

編譯器會明確告訴你, a 被理解成了 int* ,而不是陣列。

問題二:復制問題

跟上面退化問題緊密相關的一點是,C 陣列不能被復制(所以傳參有退化)。下面的程式碼無法透過編譯:

int a[3] = {1, 2, 3};int b[3] = a; // 不能編譯b = a; // 不能編譯

復制和退化這兩個問題是緊密相關的,但這種語言的不規則性還是帶來了學習和理解上的困難。如果我們想要一個陣列能夠被復制,就得把它放到結構體(或聯合體)裏面去。這至少會帶來語法上的不便。

問題三:語法問題

C 陣列的語法設計也絕對稱不上有良好的可讀性。你能一眼看出下面兩個聲明分別是什麽意思嗎?

int (*fpa[3])(constchar*);int (*(*fp)(constchar*))[3];

(下面會給出回答。)

問題四:動態問題

最早的 C 陣列大小是完全固定的,這實際上既不方便又不安全。當然,我們可以用 malloc 來動態分配記憶體,到了 C99 還可以用變長陣列,但它們要麽使用不夠方便,要麽長度不能在建立後變化(如動態增長)。這些問題使得 C 的程式碼裏常常在不該使用定長陣列的時候也使用了定長陣列,並很容易導致安全問題,如 緩沖區溢位

C++ 的解決方案

C++ 有兩種常用的替換 C 陣列的方式:

  • vector

  • array

  • vector

    C++ 標準樣版庫(STL)的主要組成部份是:

  • 容器

  • 叠代器

  • 演算法

  • 函式物件

  • 而說到容器,我們通常第一個討論的就是 vector 。它的名字來源於數學術語,直接轉譯是「向量」的意思,但在實際套用中,我們把它當成動態陣列更為合適。Alex Stepanov 在設計 STL 時借鑒 Scheme 和 Common Lisp 語言起了這個名字,但他後來承認這是個錯誤——這個容器不是數學裏的向量,名字起得並不好。它基本相當於 Java 的 ArrayList 和 Python 的 list 。C++ 裏有更接近數學裏向量的物件,名字是 valarray (很少有人使用,我也不打算介紹)。

    vector 的成員在記憶體裏連續存放。 begin end 成員函式返回的 叠代器 構成了一個半閉半開區間,而 front back 成員函式則返回指向首項和尾項的 參照 ,如下圖所示:

    因為 v ector 的元素放在堆上,它也自然可以受益於現代 C++ 的移動語意——移動 vector 具有很低的開銷,通常只是操 作六個指標而已。

    下面的程式碼展示了 vector 的基本用法:

    vector<int> v{1, 2, 3, 4};v.push_back(5);v.insert(v.begin(), 0);for (size_t i = 0; i < v.size(); ++i) {cout << v[i] << ' '; // 輸出 0 1 2 3 4}cout << '\n';int sum = 0;for (auto it = v.begin(); it != v.end(); ++it) { sum += *it;}cout << sum << '\n'; // 輸出 15

    上面的程式碼裏我們首先構造了一個內容為 {1, 2, 3, 4} vector ,然後在尾部追加一項 5 ,在開頭插入一項 0 。接下來,我們使用傳統的下標方式來遍歷,並輸出其中的每一項。隨即我們展示了 C++ 裏通用的使用叠代器遍歷的做法,對其中的內容進行累加。最後輸出結果。

    當一個容器存在 push _… pop_… 成員函式時,說明容器對指定位置的刪除和插入效能較高。 vector 適合在尾部操作,這是它的記憶體布局決定的(它只支持 push_back 而不支持 push_front )。只有在尾部插入和刪除時,其他元素才會不需要移動,除非記憶體空間不足導致需要重新分配記憶體空間。

    除了容器類的共同點, vector 允許下面的操作(不完全列表):

  • 可以使用中括弧的下標來存取其成員

  • 可以使用 data 來獲得指向其內容的裸指標

  • 可以使用 capacity 來獲得當前分配的儲存空間的大小,以元質數量計

  • 可以使用 reserve 來改變所需的儲存空間的大小,成功後 capacity() 會改變

  • 可以使用 resize 來改變其大小,成功後 size() 會改變

  • 可以使用 pop_back 來刪除最後一個元素

  • 可以使用 push_back 在尾部插入一個元素

  • 可以使用 insert 在指定位置前插入一個元素

  • 可以使用 erase 在指定位置刪除一個元素

  • 可以使用 emplace 在指定位置構造一個元素

  • 可以使用 emplace_back 在尾部新構造一個元素

  • 大家可以留意一下 push_… pop_… 成員函式。它們存在時,說明容器對指定位置的刪除和插入效能較高。 vector 適合在尾部操作,這是它的記憶體布局決定的。只有在尾部插入和刪除時,其他元素才會不需要移動,除非記憶體空間不足導致需要重新分配記憶體空間。

    pus h_back insert reserve resize 等函式導致記憶體重分配時,或當 insert erase 導致元素位置移動時, vector 會試圖把元素「移動」到新的記憶體區域。 vector 的一些重要操作(如 push_back )試圖提供強異常安全保證,即如果操作失敗(發生異常)的話, vector 的內容完全不發生變化,就像資料庫事務失敗發生了回滾一樣。如果元素型別沒有提供一個保證不拋異常的移動建構函式, vector 此時通常會使用拷貝建構函式。因此,我們如果需要用移動來最佳化自己的元素型別的話,那不僅要定義移動建構函式(和移動設定運算子,雖然 push_back 不要求),還應當將其標為 noexcept ,或只在容器中放置物件的智慧指標。

    C++11 開始提供的 emplace… 系列函式是為了提升容器的插入效能而設計的。如果你的程式碼裏有 vector<obj> v; v.push_back(Obj()) ,那把後者改成 v.emplace_back() v 的結果相同,而效能則有所不同——使用 push_back 會額外生成臨時物件,多一次(移動或拷貝)構造和析構。如果是移動的情況,那會有小幅效能損失;如果物件沒有實作移動的話,那效能差異就可能比較大了。——作為簡單的使用指南,若且唯若我們見到 v.push_back(Obj(…)) 這樣的程式碼時,我們就應當改為 v.emplace_back(…)

    array

    vector 解決了 C 陣列的所有問題,但它畢竟不等價於 C 陣列——堆記憶體分配的開銷還是要比棧高得多。效能完全等同於 C 陣列的 array 容器要到 C++11 才引入,雖然遲了點,但它最終在保留 C 陣列效能的同時消除了前面列的頭三個 C 陣列的問題。

    首先, array 沒有不會自動退化。如果你希望高效傳參,就應當用標準的參照傳參的方式,如 void foo(const array<int, 100>& a) 。如果你希望把指標傳給 C 介面,你也可以寫 foo(a.data()) 。如果函式介面就是想復制一個小陣列,那使用 void foo(array<short, 3> a) 這樣的形式也完全沒有問題。

    其次,跟上面的問題關聯, array 有了合理的復制行為。下面的程式碼完全合法:

    array<int, 3> a{1, 2, 3};array<int, 3> b = a; // OKb = a; // OK

    再次,從可讀性角度,你來自己看一下你更喜歡讀哪種風格的程式碼吧:

    // 函式指標的陣列int (*fpa[3])(constchar*);array<int (*)(constchar*), 3> fpa;// 返回整數陣列指標的函式的指標int (*(*fp)(constchar*))[3];array<int, 3>* (*fp)(constchar*);

    array 的好處還不止這些。由於它的介面跟其他的容器更一致,更容易被使用在泛型程式碼中。你也可以直接拿兩個 array 來進行 ==、< 之類的比較,結果不是 C 陣列的無聊指標比較,而是真正的逐元素比較!

    我的培訓課程

    【現代 C++ 實戰】是一個我講過很多次的培訓課程,重點在 C++ 語言提供的「現代」特性上,包括了 C++ 的主要慣用法和常用新特性。我會在課程裏討論:

  • 資源管理

  • 移動語意

  • 智慧指標

  • 容器

  • 叠代器

  • 現代 C++ 的易用性改進

  • 樣版

  • ……

  • 當然,課程是死的,課程裏的交流、課後你自己的練習和拓展才是成長的關鍵。我希望我的課程能帶給你一個看待 C++ 新視角,能在實踐中加以套用;我希望你多多提出問題,由我來為你答疑解惑;我更希望你學完不是就那麽結束了,而是牢牢記住一定要「學而時習之」,把課程的結束當成一個新的學習階段的開始。只有這樣,我的授課才不是白費力氣。

    課前熱身直播

    如果對以上的 C++培訓有興趣,也有疑問,非常 歡迎你來參加我在 3 月 27 日的免費直播課程【C++ 的序列容器】。屆時我會討論 C++ 裏的序列容器——包括上面提到的 vector array ,以及 deque list forward_list ——並進行更深入 的講解和討論。也歡迎在評論區留下你的問題,我會詳細解答。