C++ 的 string 物件到底佔幾個位元組？
Tags: cpp, string
Published: 2024-06-19 04:33:41

最近剛好遇到一個問題, 才發現 C++ 中的 [`std::string`](https://en.cppreference.com/w/cpp/string/basic_string) 配置的空間在不同版本的編譯器並不一樣, 以底下的程式碼為例：

```cpp
#include<iostream>
using namespace std;

int main(void){
    string s = "hello";
    cout << "sizeof(s):" << sizeof(s) << endl;
    return 0;
}
```

如果是在 gcc 4.9.3(原始的 [Dev-C++ 5.11](https://sourceforge.net/projects/orwelldevcpp/) 停留在 gcc 4.9.2, 若是 EMBARCADER 接手維護的 [Dev-C++ 6.3](https://www.embarcadero.com/free-tools/dev-cpp) 則是停留在 gcc 9.2.0), 執行結果如下：

```
sizeof(s):8
```

但若是在 gcc 5.1 開始, 執行結果如下：

```
sizeof(s):32
```

你可以[在這裡](https://godbolt.org/z/MnjYvz475)自由查看不同編譯器的結果。

這主要是因為在 gcc 5.1 之前, `std::string` 的實作會動態配置一塊動態變化大小的記憶體, 裡面存放字串目前長度、可容納字串長度以及字串內容, 字串物件裡面只有一個指向這塊記憶體中字串起始位址的指位器, 因此佔用了紀錄 64 位元系統位址的 8 個位元組。[整體結構](https://github.com/gcc-mirror/gcc/blob/6503c21eb9098470636326bae601a11ee4cc488e/libstdc%2B%2B-v3/include/bits/basic_string.h#L70)如下：

```
                      +-----------------
                      | size_t capacity
                      +-----------------
                      | size_t size
                      +-----------------
  s                   | szie_t refcount
  +----------         +----------------- 
  | char *ptr ------> |"hello"
  +----------         +-----------------
```

若修改字串內容, 超過可容納的最長長度, 就會重新配置整塊記憶體。其中**共用計數**是配合 **(Copy on Write, 簡稱 CoW, 修改時才複製)** 機制運作。字串共用計數預設是 0, 如果複製字串物件給另一個字串物件時, 這兩個字串物件會共用同一塊記憶體, 並遞增共用計數。等到其中一個字串物件要修改字串內容時, 會檢查共用計數, 若共用計數大於 0, 就會配置新的一塊記憶體給要修改字串的物件, 並將原本的共用計數減 1, 讓兩個字串物件使用不同區塊的記憶體。如此可以避免不必要的動態記憶體配置動作, 如果字串物件沒有修改內容, 就會持續共用記憶體, 而不需要配置新的記憶體。

但是到了 [gcc 5.1](https://isocpp.org/blog/2015/04/gcc-5.1-released) 開始, 引入了 [small string optimization(簡稱 SSO, 短字串最佳化)](https://devblogs.microsoft.com/oldnewthing/20230803-00/?p=108532) 的實作方式, 現在字串物件存放的內容變多了, 裡面會有：

```
  s
  +----------------
  | chat *ptr ----------+
  +----------------     |
  | size_t size         |
  +----------------     |
  | buf (16 bytes) <----+
  +----------------
```

`size_t` 和指位器都是 8 位元組, 所以總共佔 32 個位元組。當字串長度沒有超過 15 個字元時, 就會把字串直接儲存在 `buf` 中, 免去動態配置記憶體的時間, 加快處理短字串的效能。由於 `buf` 是固定的 16 個位元組, 所以可容納字串的最長長度就是 15(扣除結尾的 '\0')。

如果字串長度超過 15 時, 就會實際動態配置記憶體來存放, 這時結構會變成：

```
  s
  +-----------------         +----------
  | chat *ptr -------------> | buf
  +-----------------         +----------
  | size_t size         
  +-----------------     
  | size_t capacity 
  +-----------------
  | padding(8 bytes)
  +-----------------
```

原本用來存放短字串的區域就會改成用來存放可容納字串的最長長度了。也由於實作方式的差異, 所以 gcc 5.1 開始就不使用 CoW 機制了。

我們可以透過以下的程式碼來驗證：

```cpp
#include <iostream>
using namespace std;

int main(void) {
    string s = "hello, world";
    cout << "sizeof(s):" << sizeof(s) << endl;
    cout << "&s:" << &s << endl;
    cout << "s.data():" << (void *)s.data() << endl;
    cout << "s.capacity():" << s.capacity() << endl;
    cout << "s.size():" << s.size() << endl;
    cout << "*ptr:" << (*((char **)&s))
         << endl;    // 利用物件內的指位器取得字串內容
#if (__GNUC__ >= 5)  // gcc 5.1 開始的作法
    cout << "size:" << *((size_t *)&s + 1) << endl;  // 取得物件內儲存的字串長度
    cout << "*ptr:" << ((char *)&s + 16)
         << endl;  // 透過物件內的 buf 區域取得字串
#else              // gcc 4.9 及之前版本的作法
    string s2 = s;  // 複製字串
    string s3 = s;  // 複製字串
    cout << "refcount:" << *(*((size_t **)&s) - 1)
         << endl;  // 從字串位址往回取得共用計數
    cout << "capacity:" << *(*((size_t **)&s) - 2)
         << endl;  // 從字串位址往回取得可容納字串最長長度
    cout << "size:" << *(*((size_t **)&s) - 3)
         << endl;  // 從字串位址往回取得字串長度
#endif
    s = "this is a new book";
    cout << "sizeof(s):" << sizeof(s) << endl;
    cout << "&s:" << &s << endl;
    cout << "s.data():" << (void *)s.data() << endl;
    cout << "s.capacity():" << s.capacity() << endl;
    cout << "s.size():" << s.size() << endl;
    cout << "*ptr:" << (*((char **)&s))
         << endl;    // 利用物件內的指位器取得字串內容
#if (__GNUC__ >= 5)  // gcc 5.1 開始的作法
    cout << "size:" << *((size_t *)&s + 1) << endl;  // 取得物件內儲存的字串長度
    cout << "capacity:" << *((size_t *)&s + 2)
         << endl;  // 取得物件內儲存的可容納字串最長長度
#else              // gcc 4.9 及之前版本的作法
    cout << "s2 refcount:" << *(*((size_t **)&s2) - 1)
         << endl;  // 從 s2 字串位址往回取得共用計數
    cout << "s refcount:" << *(*((size_t **)&s) - 1)
         << endl;  // 從 s 字串位址往回取得共用計數
    cout << "capacity:" << *(*((size_t **)&s) - 2)
         << endl;  // 從字串位址往回取得可容納字串最長長度
    cout << "size:" << *(*((size_t **)&s) - 3)
         << endl;  // 從字串位址往回取得字串長度
#endif
    return 0;
}
```

這裡透過條件編譯以不同的方式解譯字串物件的結構, 在 gcc 4.9.3 的執行結果如下：

```
sizeof(s):8
&s:0x7ffe2bd9f7b0
s.data():0x7652b8
s.capacity():12
s.size():12
*ptr:hello, world
refcount:2
capacity:12
size:12
sizeof(s):8
&s:0x7ffe2bd9f7b0
s.data():0x7662f8
s.capacity():24
s.size():18
*ptr:this is a new book
s2 refcount:1
s refcount:0
capacity:24
size:18
```

你可以看到一開始會依照字串長度配置記憶體, 從 s 物件的位址與實際儲存字串的位址也可以知道這兩個位址相差很遠, 顯示實際儲存字串的區塊是動態配置, 你也可以看到字串的長度與可容納最長長度都是 12。你也可以透過指位器運算, 經由剛剛解釋的結構取得個別資訊。我們也特亦將此字串物件複製兩次, 因此可以看到共用計數為 2, 表示有另外兩個字串物件共用這塊記憶體。

一旦將字串內容改成比較長的內容, 就可以看到因為重新配置記憶體, 所以實際儲存字串內容的位址改變了, 而且可容納字串的最長長度也不一樣, 變成 24 了。也因為修改字串, 所以會配置新的區塊存放, 你可以看到原本區塊的共用計數會減 1, 從 2 變為 1, 表示現在只有 1 的字串物件共用原本的區塊, 而新配置的區塊上共用計數則是 0。

如果是 gcc 5.1, 結果如下：

```
sizeof(s):32
&s:0x7ffda2e7f6d0
s.data():0x7ffda2e7f6e0
s.capacity():15
s.size():12
*ptr:hello, world
size:12
*ptr:hello, world
sizeof(s):32
&s:0x7ffda2e7f6d0
s.data():0x1a59ec0
s.capacity():30
s.size():18
*ptr:this is a new book
size:18
capacity:30
```

一開始因為是使用物件內固定大小的區塊儲存字串, 所以你可以看到 s 物件的位址與實際儲存字串內容的位址就相差 16, 如同前面解說的結構, 而且因為可存放區域的大小是固定的, 所以可容納字串的最長長度就是 15。你同樣可以利用指位器運算, 依循剛剛解說的結構取得個別資料。

一旦將字串長度加長到超過 15, 就會迫使程式動態配置記憶體, 你可看到 s 物件的位址不變, 但是實際儲存字串的位址已經改變了, 完全不在 s 物件內。現在可容納字串的最長長度也不再是 15, 而是新配置的記憶體大小 30 了。

你也可以在[以下這裡](https://godbolt.org/z/GMf4KWEoo)自己測試看看不同版本編譯器的結果。