(原文出處:https://ericlippert.com/2003/09/12/erics-complete-guide-to-bstr-semantics/


如果你曾經用 C 或 C++ 寫過任何使用到 COM 物件的程式,你一定看過類似的程式碼:

STDMETHODIMP CFoo:Bar(BSTR bstrABC)
{ ... }

這個 BSTR 到底是三小?它和 WCHAR* 又有什麼區別?

像 C 或 C++ 這種低階語言,你有絕對的自由可以決定:究竟要用什麼方式實作某種概念。Unicode 字串就是個絕佳範例。用 C++ 來表示長度為 n 個字元的 Unicode 字串的標準方法是一個指向 2 * (n + 1) bytes 記憶體空間的指標。這塊空間中的前 2 * n bytes 是用來表示 UTF-16 編碼字元的無號短整數 (unsigned short integers),最後 2 個 bytes 的內容則是 0,用來表示字串的結尾。

為了表示上的方便,我們利用「匈牙利表示法」,稱呼這樣的怪物為 PWSZ,意即「指向寬字元的指標,以零結尾 (Pointer to Wide-character String, Zero-terminated)」。以 C++ 的型別系統來說,PWSZ 就是一個 unsigned short *

COM 儲存字串資料的方法有一點點不同,這種方法和前述的方法類似,以確保用 PWSZ 方式處理字串的程式碼、以及提供 COM 格式字串的程式碼之間,可以良好溝通。不幸的是,這兩者之間的差異並不小。如果你不夠小心,或是不了解這些差異的話,這些細微的差異就會導致令人困擾的錯誤。

COM 的程式碼使用 BSTR 來儲存 Unicode 字串。BSTR 是 "Basic STRing" 的縮寫。之所以這麼命名,是因為這種儲存字串的方法是為了 OLE Automation 開發的,而 OLE Automation 當時又是因為 Visual Basic 語言引擎的開發而開始的。(譯註:所以 BSTR 的 "Basic" 指的就是 "Visual Basic",而不是「基本」。)

從編譯器的角度來看,BSTR 也是一個 unsigned short *。如果你在需要 PWSZ 的地方使用 BSTR,編譯器不會在意,反之亦然。但這並不代表你可以肆無忌憚地這麼做!如果兩者完全一樣,那就不需要分別用不同的名稱表示了。這兩種型別之間有許多不同之處。

在大部份的情況下,BSTR 可以被當成 PWSZ 來使用。但 PWSZ 只有在極少數的情況下,才能被當成 BSTR 來使用。

讓我先列出這兩者之間的區別,同時詳細說明每一點。

  1. 對於 BSTRNULL"" 在語意上必須完全相同。而對於 PWSZ,這兩者的語意通常是不同的。
  2. BSTR 物件必須以 SysAlloc 家族的函式來配置/釋放記憶體。PWSZ 物件則可以是 stack 上自動配置的儲存空間,或是使用 mallocnewLocalAlloc 或任何其他的記憶體配置函式來配置記憶體。
  3. BSTR 字串的長度是固定的。PWSZ 的長度可以是任意值,只受到其指向的 buffer 的有效記憶體大小限制。
  4. BSTR 永遠指向 buffer 中的第一個有效字元。PWSZ 可能是個指向字串 buffer 中間或結束位址的指標。
  5. 當你配置了 n bytes 給 BSTR 時,這個字串可以存放 n/2 個寬字元。當你配置了 n bytes 給 PWSZ 時,你可以存放 n / 2 - 1 個字元,因為你必須保留最後一個字元的空間存放表示結尾的零字元。
  6. BSTR 宇串中可能包含任何的 Unicode 資料,包括零字元。PWSZ 除了結尾的 零字元以外,不得包含任何的零字元。不管是 BSTR 還是 PWSZ,在最後一個有效字元的後面,都會有一個零字元;但是在 BSTR 中,零字元也可以是有效字元。
  7. BSTR 的 byte 長度其實可以是奇數 -- 它可以用來傳遞二進位資料,雖然我不建議這麼做。PWSZ 的 byte 長度則一定是偶數,而且只能用來儲存 Unicode 字串。

多年來,我發現、修復了無數的錯誤,都是因為程式碼原作者假設 PWSZ 可以當作 BSTR(或反過來)使用,進而違反了上述的差異之一。讓我們仔細來討論這些差異吧:

  1. 如果你寫的函式接受一個型別為 BSTR 的參數,那麼你就必須接受 NULL 是個有效的 BSTR 字串,而且把它視為指向空字串(即長度為 0 的字串)的指標。COM 照著這個規則走,而且 Visual Basic 和 VBScript 也是如此。所以如果你想跟別人一起玩,你就得遵守這個遊戲規則。如果 VB 中的某個字串變數恰好是空字串,那麼 VB 傳進來的參數就有可能是 NULL,也有可能是指向空字串的指標 -- 完全視該 VB 程式的內部處理方式而定。對於使用 PWSZ 的程式碼來說,通常不是這樣的:通常 NULL 指的是「這個字串值不存在」,而不是等同於空字串。
    在 COM 中,如果你有一個「可能存在、也可能不存在」的資料,那麼你應該要用 VARIANT 來儲存,然後用 VT_NULL 代表數字不存在;而不是將 NULL 解譯為空字串以外的意義。

  2. BSTR 字串必須要使用 SysAllocStringSysAllocStringLenSysFreeString 等函式來分配、釋放記憶體。底層的記憶體是由作業系統快取的,若是對 BSTR 使用 free 或是 delete 會造成 heap 損毀的嚴重錯誤。同理,使用 mallocnew 來配置 buffer 空間、再指派給 BSTR 物件,也一樣會造成錯誤。作業系統內部的程式碼對 BSTR 在記憶體中的佈局做了假設,嘗試去模擬這樣的佈局是不智之舉。相對來說,PWSZ 則可以使用任何的分配方式來配置記憶體,也可以直接配置在 stack 空間中。

  3. BSTR 中,字元的數量是固定的。一個長度為 10 bytes 的 BSTR,裡面包含了 5 個 UTF-16 的字元,就這樣。就算這些字元都是 0,它還是包含了 5 個 零字元。另一方面,PWSZ 可以包含「不超過 buffer 大小」的字元數:

    WCHAR pwszBuf[101];
    pwszBuf[0] = 'X';
    pwszBuf[1] = '';

    現在 pwszBuf 是個包含有一個字元的字串,而且可以最多增長到 100 個字元,或是縮短成 0 個字元的空字串。

  4. BSTR 指標永遠指向 buffer 中的第一個有效字元。下面這樣做是不合法的:

    BSTR bstrName = SysAllocString(L"John Doe");
    BSTR bstrLast = &bstrName[5]; // 錯誤

    bstrLast 不是合法的 BSTR。但對 PWSZ 來說,這麼做是完全合法的:

    WCHAR * pwszName = L"John Doe";
    WCHAR * pwszLast = &pwszName[5];
  5. 請參照 (6)。

  6. 當你了解 BSTR 在記憶體中的佈局到底是怎樣之後,就能更清楚為什麼會有上述的那些限制,同時也能解釋為什麼配置了 n 個字元的 BSTR 可以存放 n 個字元,而不是像 PWSZ 一樣只能放 n - 1 個字元。當你呼叫 SysAllocString(L"ABCDE") 時,作業系統其實會幫你配置 16 bytes 的空間。最前面的 4 bytes 是一個 32-bit 的整數,用來表示「這個字串中的有效 bytes 數量」 -- 在這個例子中會被填入 10。接下來的 10 個 bytes 的所有權屬於呼叫者,而內容則會是透過配置函式傳入的資料。最後的兩個 bytes 會被填入 0。然後你會得到一個指向資料(而非標頭)的指標。(譯註:示意圖如下)

    這立刻解釋了幾個關於 BSTR 的事實:

    • 字串長度可以立刻得知。SysStringLen 並不會像 wcslen 一樣一個字元一個字元算到零字元。它可以直接讀取指標前面的整數,立刻回傳。
    • 這也是為什麼 BSTR 指標不可以指向另一個 BSTR 字串中間的任意一個字元。因為這個指標的前面不會是字串的長度。
    • BSTR 可以被當成 PWSZ 使用,因為配置函式一定會幫你在字串的結尾加上一個零字元。身為呼叫者的你,不需要擔心是否配置了足夠放置零字元的記憶體。如果你需要 5 個字元長的字串,直接跟配置函式要求 5 個字元就好。
    • 這也是為什麼 BSTR 必須使用 SysAlloc 系列的函式來配置/釋放。只有這函式才能完全了解這些幕後的細節。
  7. 因為 BSTR 的 bytes 長度是已知的,所以不需要使用「零字元代表字串結束」這樣的慣例。因此,在 BSTR 中,零字元是合法的字元。這表示 BSTR 可以包含任意的資料,表括二進制映像資料。因為這個原因,BSTR 除了可以處理字串資料外,還常被當成一種處理二進位資料的方便方法。這意味著在某些特殊的情況下,它的長度可能會是奇數。這種情況很少見,但不是完全不可能發生,你得特別注意。我建議不要這麼做,但你還是有可能碰到。

呼,終於!總結來說,這應該可以解釋為什麼 BSTR 通常可以當成 PWSZ 使用,但 PWSZ 卻不能當做 BSTR 來用,除非它真的是 BSTRBSTR 只有在下面這些情況下才 不能 被當成 PWSZ 使用:

  • BSTRNULL
  • BSTR 的內容中有零字元時(因為處理 PWSZ 的程式碼會把零字元當成字串結尾,導致程式會認為字串長度比實際上的短)
  • BSTR 所包含的其實不是字串,是二進位資料時

唯一一個可以把 PWSZ 當成 BSTR 使用的情況,就是那個 PWSZ 其實 就是 BSTR,而且經過正確的配置程式配置空間。

在我自己寫的 C++ 程式碼中,我會很小心地使用匈牙利命名法,記錄指標所指向的資料為何,以避免不必要的誤解。使用匈牙利命名法時,要能捕捉到變數的語意資訊,才會有最好的效果。以下是我使用的慣例:

  • bstr:真正的 BSTR
  • pwsz:指向「以零字元結尾」的寬字元字串的指標
  • psz:指向「以零字元結尾」的窄字元字串的指標
  • ch:字元
  • pch:指向一個寬字元的指標
  • cch:字元個數
  • b:一個 byte
  • pb:指向一個 byte 的指標
  • cb:byte 個數

By closer

發表迴響