在 Windows 之中,想將標準字元 (char) 字串轉換成寬字元 (wchar_t) 字串,通常會使用 MultiByteToWideChar() 這個 Win32 API。這個 API 的 prototype 如下:

int MultiByteToWideChar(
    UINT CodePage,              // [in] 說明輸入字串的編碼。本文假設都為 UTF-8。
    DWORD dwFlags,              // [in] 選項旗標。本文不討論。
    LPCSTR lpMultiByteStr,      // [in] 指向輸入字串的指標。
    int cchMultiByte,           // [in] 指定要處理輸入字串 char 個數。-1 表示處理到結尾的空字元。
    LPWSTR lpWideCharStr,       // [out; optional] 輸出的 buffer。
    int cchWideChar             // [in] 描述 lpWideCharStr 的 buffer 大小,單位為 sizeof(wchar_t)。
);

比較有趣的設計是:如果 lpWideCharStr 傳入的值是 nullptr,而 cchWideChar 是 0 的話,這個 API 的回傳值就會是「該字串完成轉換後所需要的 buffer 大小」。這是因為在不同的編碼下每個「文字」的大小可能不一樣;混用各種文字的字串,經過編碼轉換後,所需要的長度並沒有一定的公式可循,必須得真的轉換過一次,才能得知所需的長度 。

由於考慮到記憶體配置與安全性的問題,一般人在使用這個 API 時,會呼叫兩次:第一次取得所需的 buffer 大小、配置適當的記憶體後,再呼叫第二次,把轉換結果填入 buffer。把整個流程寫成函式的話,會長成這個樣子:

std::wstring WCS_from_UTF8(const char* in_str) {
  size_t cch_in = strlen(in_str);
  // ---- 第一次呼叫,取得所需的 buffer size
  int buf_size = ::MultiByteToWideChar(
      CP_UTF8,      // 可以處理 ANSI 和 UTF-8 字串,已經夠用。
      0,            // 暫不考慮。
      in_str,
      -1,           // 處理到結尾的空字元
      nullptr,      // 先不輸出到 buffer
      0             // 先不輸出到 buffer
  );
  wchar_t* out_buf = new wchar_t[buf_size]; // buf_size 已經把空字元算進去了,所以不需要額外加 1。
  // ---- 第二次呼叫,正式取得轉換後的結果
  ::MultiByteToWideChar(
      CP_UTF8,
      0,
      in_str,
      -1,           // 以上參數都不變
      out_buf,      // 結果會填入 out_buf
      buf_size
  );
  std::wstring wstr{out_buf};   // 將結果轉成 std::wstring,這樣才不怕 client 忘記歸還記憶體。
  delete[] out_buf;             // 記得要歸還暫存用的 buffer
  return wstr;
}

同一個 API 要呼叫兩次看起來有點礙眼,但好歹是可以正常運作的,而且好像真的也沒什麼方法可以得知所需的記憶體,我也就當作這是最佳解了。

....直到我今天無意見看到一篇 MFC 的 Technical Note

那篇文章在解釋 MFC 提供的編碼轉換巨集所使用的技巧。為了追求執行效率,這些巨集使用了兩個技巧:

  1. 利用 _alloca() 來取代 new,因為前者在 stack(而非後者會使用的 heap)配置記憶體。在 stack 配置記憶體的速度比 heap 快很多,而且也不需要手動釋放記憶體(因為函式 return 時 stack 的記憶體就會被自動歸還了)。
  2. 配置 output buffer 所需的記憶體時,配置稍微多一點的記憶體,這樣就可以省掉「取得所需的 buffer size」的那次 MultiByteToWideChar() 呼叫了。

我們先來看第二點:這個「稍微多一點」到底要多多少才夠用呢?我們可以來分析一下。

我們的來源字串,是用 UTF-8 編碼的。針對 Unicode 的碼點 (code point) 所在範圍不同,UTF-8 可能用 1 到 4 個 byte 來表示。

碼點範圍 UTF-8 所需大小 UTF-16 所需大小 轉換後大小倍數 說明
0x00 ~ 0x7F 1 byte 2 byte 2 即原本 7-bit ASCII 碼的範圍
0x080 ~ 0x7FF 2 bytes 2 bytes 1
0x07FF ~ 0xFFFF 3 bytes 2 bytes 0.67 小於 0xFFFF 的碼點都屬於基本多文種平面(Basic Multilingual Plane, BMP),因此 UTF-16 還是只需要 1 個 wchar_t(也就是 2 bytes)就可以儲存。
0x10000 ~ 10FFFF 4 bytes 4 bytes 1 超出 BMP 的碼點 UTF-16 需要使用代理對 (Surrogate Pair) 來表示,所以需要 2 個 wchar_t,即 4 bytes。

經過上述的分析後,我們可以知道:長度為 N bytes(也就是 N 個 char)的 UTF-8 字串,轉換為 UTF-16 後,長度不會超過 2N bytes(也就是 N 個 wchar_t)。所以,我們只要用 strlen() 算出原始字串的 char 個數後,再保留「同樣個數 + 1(字串結尾空字元)」個 wchar_t 做為輸出 buffer 即可。

接下來回頭看第一點。我也是看了這個說明,才知道有 _alloca() 這個函式、可以在 stack 動態配置記憶體。不過如果我們去看 _alloca() 的說明文件,會發現現在已經不建議使用這個函式了。原因是通常程式保留的 stack 都會比 heap 小,如果不小心傳入太大的值進 _alloca(),可能會導致 stack overflow,輕一點的話程式直接當掉,重一點可能會導致安全性問題。所以文件中建議改用 _malloca()

如果使用者輸入的 buffer size 不會太大,那麼 _malloca() 就會在 stack 配置記憶體;但如果 size 太大,則會改從 heap 配置。可以兼顧效率以及安全性。然而,和 _alloca() 不同的是,利用 _malloca() 配置的記憶體,一定要使用 _freea() 歸還!這會讓程式寫起來沒有那麼「優雅」...... 不過在正確性前面,優雅也只能犧牲了。

綜合上述的討論,順便加上一些保護機制(如果空間配置 or 轉換時遇到問題,就直接回傳空字串),我們可以把 WCS_from_UTF8() 改寫如下:

wstring WCS_from_UTF8(const char* in_str) {
    wstring wcs;
    size_t cch_in = strlen(in_str);
    if (cch_in) {
        wchar_t* out_buf = reinterpret_cast<wchar_t*>(_malloca(
            (cch_in + 1) * sizeof(wchar_t)));
        if (out_buf) {
            int out_count = ::MultiByteToWideChar(
                CP_UTF8,
                0,
                in_str,
                -1,
                out_buf,
                static_cast<int>(cch_in + 1)  // 長度必須包含結尾的空字元
            );
            if (out_count > 0) {
                // out_count 包含了結尾空字元。
                // 但使用 basic_string::assign 時,不應該包含空字元,
                // 否則之後 append text 會出問題。因此這邊要減 1。
                wcs.assign(out_buf, static_cast<size_t>(out_count - 1));
            }
            _freea(out_buf);    // 別忘了釋放記憶體
        }
    }
    return wcs;
}

透過相似的分析,也可以找出從 UTF-16 轉成 UTF-8 時,所需要的最大 buffer size 為 wchar_t 個數的 3 倍再加 1。範例程式如下:

std::string UTF8_from_WCS(const wchar_t* in_wcs) {
    std::string u8str;
    size_t wcs_count = wcslen(in_wcs);
    if (wcs_count) {
        int buf_len = wcs_count * 3 + 1;
        char* out_buf = reinterpret_cast<char*>(_malloca(buf_len * sizeof(char)));
        if (out_buf) {
            int out_count = ::WideCharToMultiByte(
                CP_UTF8,
                0,
                in_wcs,
                -1,
                out_buf,
                buf_len,
                NULL,       // 使用 CP_UTF8 時,這個參數必須是 NULL
                NULL        // 使用 CP_UTF8 時,這個參數必須是 NULL
            );
            if (out_count > 0) {
                // out_count 包含了結尾空字元。
                // 但使用 basic_string::assign 時,不應該包含空字元,
                // 否則之後 append text 會出問題。因此這邊要減 1。
                u8str.assign(out_buf, static_cast<size_t>(out_count - 1));
            }
            _freea(out_buf);    // 別忘了釋放記憶體
        }
    }
    return u8str;
}

By closer

發表迴響