原文出處:CodeProject:The Complete Guide to C++ Strings, Part I
引言
毫無疑問,我們都看到過像 TCHAR, std::string, BSTR 等各種各樣的字串類型,還有那些以 _tcs 開頭的奇怪的宏。你也許正在盯著顯示器發愁。本指引將總結引進各種字元類型的目的,展示一些簡單的用法,並告訴您在必要時,如何?各種字串類型之間的轉換。
在第一部分,我們將介紹3種字元編碼類型。瞭解各種編碼模式的工作方式是很重要的事情。即使你已經知道一個字串是一個字元數組,你也應該閱讀本部分。一旦你瞭解了這些,你將對各種字串類型之間的關係有一個清楚地瞭解。
在第二部分,我們將單獨講述string類,怎樣使用它及實現他們相互之間的轉換。
字元基礎 -- ASCII, DBCS, Unicode
所有的 string 類都是以C-style字串為基礎的。C-style 字串是字元數組。所以我們先介紹字元類型。這裡有3種編碼模式對應3種字元類型。第一種編碼類別型是單子節字元集(single-byte character set or SBCS)。在這種編碼模式下,所有的字元都只用一個位元組表示。ASCII是SBCS。一個位元組表示的0用來標誌SBCS字串的結束。
第二種編碼模式是多位元組字元集(multi-byte character set or MBCS)。一個MBCS編碼包含一些一個位元組長的字元,而另一些字元大於一個位元組的長度。用在Windows裡的MBCS包含兩種字元類型,單位元組字元(single-byte characters)和雙位元組字元(double-byte characters)。由於Windows裡使用的多位元組字元絕大部分是兩個位元組長,所以MBCS常被用DBCS代替。
在DBCS編碼模式中,一些特定的值被保留用來表明他們是雙位元組字元的一部分。例如,在Shift-JIS編碼中(一個常用的日文編碼模式),0x81-0x9f之間和 0xe0-oxfc之間的值表示"這是一個雙位元組字元,下一個子節是這個字元的一部分。"這樣的值被稱作"leading bytes",他們都大於0x7f。跟隨在一個leading byte子節後面的位元組被稱作"trail byte"。在DBCS中,trail byte可以是任意非0值。像SBCS一樣,DBCS字串的結束標誌也是一個單位元組表示的0。
第三種編碼模式是Unicode。Unicode是一種所有的字元都使用兩個位元組編碼的編碼模式。Unicode字元有時也被稱作寬字元,因為它比單子節字元寬(使用了更多的儲存空間)。注意,Unicode不能被看作MBCS。MBCS的獨特之處在於它的字元使用不同長度的位元組編碼。Unicode字串使用兩個位元組表示的0作為它的結束標誌。
單位元組字元包含拉丁文字母表,accented characters及ASCII標準和DOS作業系統定義的圖形字元。雙位元組字元被用來表示東亞及中東的語言。Unicode被用在COM及Windows NT作業系統內部。
你一定已經很熟悉單位元組字元。當你使用char時,你處理的是單位元組字元。雙位元組字元也用char類型來進行操作(這是我們將會看到的關於雙子節字元的很多奇怪的地方之一)。Unicode字元用wchar_t來表示。Unicode字元和字串常量用首碼L來表示。例如:
wchar_t wch = L''1''; // 2 bytes, 0x0031wchar_t* wsz = L"Hello"; // 12 bytes, 6 wide characters
字元在記憶體中是怎樣儲存的
單位元組字串:每個字元佔一個位元組按順序依次儲存,最後以單位元組表示的0結束。例如。"Bob"的存貯形式如下:
Unicode的儲存形式,L"Bob"
42 00 |
6F 00 |
62 00 |
00 00 |
B |
o |
b |
BOS |
使用兩個位元組表示的0來做結束標誌。
一眼看上去,DBCS 字串很像 SBCS 字串,但是我們一會兒將看到 DBCS 字串的微妙之處,它使得使用字串操作函數和永字元指標遍曆一個字串時會產生預料之外的結果。字串" " ("nihongo")在記憶體中的儲存形式如下(LB和TB分別用來表示 leading byte 和 trail byte)
93 FA |
96 7B |
8C EA |
00 |
LB TB |
LB TB |
LB TB |
EOS |
|
|
|
EOS |
值得注意的是,"ni"的值不能被解釋成WORD型值0xfa93,而應該看作兩個值93和fa以這種順序被作為"ni"的編碼。
使用字串處理函數
我們都已經見過C語言中的字串函數,strcpy(), sprintf(), atoll()等。這些字串只應該用來處理單位元組字元字串。標準庫也提供了僅適用於Unicode類型字串的函數,比如wcscpy(), swprintf(), wtol()等。
微軟還在它的CRT(C runtime library)中增加了操作DBCS字串的版本。Str***()函數都有對應名字的DBCS版本_mbs***()。如果你料到可能會遇到DBCS字串(如果你的軟體會被安裝在使用DBCS編碼的國家,如中國,日本等,你就可能會),你應該使用_mbs***()函數,因為他們也可以處理SBCS字串。(一個DBCS字串也可能含有單位元組字元,這就是為什麼_mbs***()函數也能處理SBCS字串的原因)
讓我們來看一個典型的字串來闡明為什麼需要不同版本的字串處理函數。我們還是使用前面的Unicode字串 L"Bob":
42 00 |
6F 00 |
62 00 |
00 00 |
B |
o |
b |
BOS |
因為x86CPU是little-endian,值0x0042在記憶體中的儲存形式是42 00。你能看出如果這個字串被傳給strlen()函數會出現什麼問題嗎?它將先看到第一個位元組42,然後是00,而00是字串結束的標誌,於是strlen()將會返回1。如果把"Bob"傳給wcslen(),將會得出更壞的結果。wcslen()將會先看到0x6f42,然後是0x0062,然後一直讀到你的緩衝區的末尾,直到發現00 00結束標誌或者引起了GPF。
到目前為止,我們已經討論了str***()和wcs***()的用法及它們之間的區別。Str***()和_mbs**()之間的有區別區別呢?明白他們之間的區別,對於採用正確的方法來遍曆DBCS字串是很重要的。下面,我們將先介紹字串的遍曆,然後回到str***()與_mbs***()之間的區別這個問題上來。
正確的遍曆和索引字串
因為我們中大多數人都是用著SBCS字串成長的,所以我們在遍曆字串時,常常使用指標的++-和-操作。我們也使用數組下標的表示形式來操作字串中的字元。這兩種方式是用於SBCS和Unicode字串,因為它們中的字元有著相同的寬度,編譯器能正確的返回我們需要的字元。
然而,當碰到DBCS字串時,我們必須拋棄這些習慣。這裡有使用指標遍曆DBCS字串時的兩條規則。違背了這兩條規則,你的程式就會存在DBCS有關的bugs。
1.在前向遍曆時,不要使用++操作,除非你每次都檢查lead byte;
2.永遠不要使用-操作進行後向遍曆。
我們先來闡述規則2,因為找到一個違背它的真實的執行個體代碼是很容易的。假設你有一個程式在你自己的目錄裡儲存了一個設定檔案,你把安裝目錄儲存在註冊表中。在運行時,你從註冊表中讀取安裝目錄,然後合成設定檔名,接著讀取該檔案。假設,你的安裝目錄是C:\Program Files\MyCoolApp,那麼你合成的檔案名稱應該是C:\Program Files\MyCoolApp\config.bin。當你進行測試時,你發現程式運行正常。
現在,想象你合成檔案名稱的代碼可能是這樣的:
bool GetConfigFileName ( char* pszName, size_t nBuffSize ){ char szConfigFilename[MAX_PATH]; // Read install dir from registry... we''ll assume it succeeds. // Add on a backslash if it wasn''t present in the registry value. // First, get a pointer to the terminating zero. char* pLastChar = strchr ( szConfigFilename, '''' ); // Now move it back one character. pLastChar--; if ( *pLastChar != ''\\'' ) strcat ( szConfigFilename, "\\" ); // Add on the name of the config file. strcat ( szConfigFilename, "config.bin" ); // If the caller''s buffer is big enough, return the filename. if ( strlen ( szConfigFilename ) >= nBuffSize ) return false; else { strcpy ( pszName, szConfigFilename ); return true; }}
這是一段很健壯的代碼,然而在遇到 DBCS 字元時它將會出錯。讓我們來看看為什麼。假設一個日本使用者使用了你的程式,把它安裝在 C:\。下面是這個名字在記憶體中的儲存形式:
43 |
3A |
5C |
83 88 |
83 45 |
83 52 |
83 5C |
00 |
|
|
|
LB TB |
LB TB |
LB TB |
LB TB |
|
C |
: |
\ |
|
|
|
|
EOS |
當使用 GetConfigFileName() 檢查尾部的''\\''時,它尋找安裝目錄名中最後的非0位元組,看它是等於''\\''的,所以沒有重新增加一個''\\''。結果是代碼返回了錯誤的檔案名稱。
哪裡出錯了呢?看看上面兩個被用藍色高量顯示的位元組。斜杠''\\''的值是0x5c。'' ''的值是83 5c。上面的代碼錯誤的讀取了一個 trail byte,把它當作了一個字元。
正確的後向遍曆方法是使用能夠識別DBCS字元的函數,使指標移動正確的位元組數。下面是正確的代碼。(指標移動的地方用紅色標明)
bool FixedGetConfigFileName ( char* pszName, size_t nBuffSize ){ char szConfigFilename[MAX_PATH]; // Read install dir from registry... we''ll assume it succeeds. // Add on a backslash if it wasn''t present in the registry value. // First, get a pointer to the terminating zero. char* pLastChar = _mbschr ( szConfigFilename, '''' ); // Now move it back one double-byte character. pLastChar = CharPrev ( szConfigFilename, pLastChar ); if ( *pLastChar != ''\\'' ) _mbscat ( szConfigFilename, "\\" ); // Add on the name of the config file. _mbscat ( szConfigFilename, "config.bin" ); // If the caller''s buffer is big enough, return the filename. if ( _mbslen ( szInstallDir ) >= nBuffSize ) return false; else { _mbscpy ( pszName, szConfigFilename ); return true; }}
上面的函數使用CharPrev() API使pLastChar向後移動一個字元,這個字元可能是兩個位元組長。在這個版本裡,if條件正常工作,因為lead byte永遠不會等於0x5c。
讓我們來想象一個違背規則1的場合。例如,你可能要檢測一個使用者輸入的檔案名稱是否多次出現了'':''。如果,你使用++操作來遍曆字串,而不是使用CharNext(),你可能會發出不正確的錯誤警告如果恰巧有一個trail byte它的值的等於'':''的值。
與規則2相關的關於字串索引的規則:
2a. 永遠不要使用減法去得到一個字串的索引。
違背這條規則的代碼和違背規則2的代碼很相似。例如,
char* pLastChar = &szConfigFilename [strlen(szConfigFilename) - 1];
這和向後移動一個指標是同樣的效果。
回到關於str***()和_mbs***()的區別
現在,我們應該很清楚為什麼_mbs***()函數是必需的。Str***()函數根本不考慮DBCS字元,而_mbs***()考慮。如果,你調用strrchr("C:\\ ", ''\\''),返回結果可能是錯誤的,然而_mbsrchr()將會認出最後的雙位元組字元,返回一個指向真的''\\''的指標。
關於字串函數的最後一點:str***()和_mbs***()函數認為字串的長度都是以char來計算的。所以,如果一個字串包含3個雙位元組字元,_mbslen()將會返回6。Unicode函數返回的長度是按wchar_t來計算的。例如,wcslen(L"Bob")返回3。
Win32 API中的MBCS和Unicode
兩組 APIs:
儘管你也許從來沒有注意過,Win32中的每個與字串相關的API和message都有兩個版本。一個版本接受MBCS字串,另一個接受Unicode字串。例如,根本沒有SetWindowText()這個API,相反,有SetWindowTextA()和SetWindowTextW()。尾碼A表明這是MBCS函數,尾碼W表示這是Unicode版本的函數。
當你 build 一個 Windows 程式,你可以選擇是用 MBCS 或者 Unicode APIs。如果,你曾經用過VC嚮導並且沒有改過預先處理的設定,那表明你用的是MBCS版本。那麼,既然沒有 SetWindowText() API,我們為什麼可以使用它呢?winuser.h標頭檔包含了一些宏,例如:
BOOL WINAPI SetWindowTextA ( HWND hWnd, LPCSTR lpString );BOOL WINAPI SetWindowTextW ( HWND hWnd, LPCWSTR lpString ); #ifdef UNICODE#define SetWindowText SetWindowTextW#else#define SetWindowText SetWindowTextA#endif
當使用MBCS APIs來build程式時,UNICODE沒有被定義,所以前置處理器看到:
#define SetWindowText SetWindowTextA
這個宏定義把所有對SetWindowText的調用都轉換成真正的API函數SetWindowTextA。(當然,你可以直接調用SetWindowTextA() 或者 SetWindowTextW(),雖然你不必那麼做。)
所以,如果你想把預設使用的API函數變成Unicode版的,你可以在前置處理器設定中,把_MBCS從預定義的宏列表中刪除,然後添加UNICODE和_UNICODE。(你需要兩個都定義,因為不同的標頭檔可能使用不同的宏。) 然而,如果你用char來定義你的字串,你將會陷入一個尷尬的境地。考慮下面的代碼:
HWND hwnd = GetSomeWindowHandle();char szNewText[] = "we love Bob!";SetWindowText ( hwnd, szNewText );
在前置處理器把SetWindowText用SetWindowTextW來替換後,代碼變成:
HWND hwnd = GetSomeWindowHandle();char szNewText[] = "we love Bob!";SetWindowTextW ( hwnd, szNewText );
看到問題了嗎?我們把單位元組字串傳給了一個以Unicode字串做參數的函數。解決這個問題的第一個方案是使用 #ifdef 來包含字串變數的定義:
HWND hwnd = GetSomeWindowHandle();#ifdef UNICODEwchar_t szNewText[] = L"we love Bob!";#elsechar szNewText[] = "we love Bob!";#endifSetWindowText ( hwnd, szNewText );
你可能已經感受到了這樣做將會使你多麼的頭疼。完美的解決方案是使用TCHAR.
使用TCHAR
TCHAR是一種字串類型,它讓你在以MBCS和UNNICODE來build程式時可以使用同樣的代碼,不需要使用繁瑣的宏定義來包含你的代碼。TCHAR的定義如下:
#ifdef UNICODEtypedef wchar_t TCHAR;#elsetypedef char TCHAR;#endif
所以用MBCS來build時,TCHAR是char,使用UNICODE時,TCHAR是wchar_t。還有一個宏來處理定義Unicode字串常量時所需的L首碼。
#ifdef UNICODE#define _T(x) L##x#else#define _T(x) x#endif
##是一個預先處理操作符,它可以把兩個參數連在一起。如果你的代碼中需要字串常量,在它前面加上_T宏。如果你使用Unicode來build,它會在字串常量前加上L首碼。
TCHAR szNewText[] = _T("we love Bob!");
像是用宏來隱藏SetWindowTextA/W的細節一樣,還有很多可以供你使用的宏來實現str***()和_mbs***()等字串函數。例如,你可以使用_tcsrchr宏來替換strrchr()、_mbsrchr()和wcsrchr()。_tcsrchr根據你預定義的宏是_MBCS還是UNICODE來擴充成正確的函數,就像SetWindowText所作的一樣。
不僅str***()函數有TCHAR宏。其他的函數如, _stprintf(代替sprinft()和swprintf()),_tfopen(代替fopen()和_wfopen())。 MSDN中"Generic-Text Routine Mappings."標題下有完整的宏列表。
字串和TCHAR typedefs
由於Win32 API文檔的函數列表使用函數的常用名字(例如,"SetWindowText"),所有的字串都是用TCHAR來定義的。(除了XP中引入的只適用於Unicode的API)。下面列出一些常用的typedefs,你可以在msdn中看到他們。
type |
Meaning in MBCS builds |
Meaning in Unicode builds |
WCHAR |
wchar_t |
wchar_t |
LPSTR |
zero-terminated string of char (char*) |
zero-terminated string of char (char*) |
LPCSTR |
constant zero-terminated string of char (const char*) |
constant zero-terminated string of char (const char*) |
LPWSTR |
zero-terminated Unicode string (wchar_t*) |
zero-terminated Unicode string (wchar_t*) |
LPCWSTR |
constant zero-terminated Unicode string (const wchar_t*) |
constant zero-terminated Unicode string (const wchar_t*) |
TCHAR |
char |
wchar_t |
LPTSTR |
zero-terminated string of TCHAR (TCHAR*) |
zero-terminated string of TCHAR (TCHAR*) |
LPCTSTR |
constant zero-terminated string of TCHAR (const TCHAR*) |
constant zero-terminated string of TCHAR (const TCHAR*) |
何時使用 TCHAR 和 Unicode
到現在,你可能會問,我們為什麼要使用Unicode。我已經用了很多年的char。下列3種情況下,使用Unicode將會使你受益:
1.你的程式只運行在Windows NT系統中。
2. 你的程式需要處理超過MAX_PATH個字元長的檔案名稱。
3. 你的程式需要使用XP中引入的只有Unicode版本的API.
Windows 9x 中大多數的 API 沒有實現 Unicode 版本。所以,如果你的程式要在windows 9x中運行,你必須使用MBCS APIs。然而,由於NT系統內部都使用Unicode,所以使用Unicode APIs將會加快你的程式的運行速度。每次,你傳遞一個字串調用MBCS API,作業系統會把這個字串轉換成Unicode字串,然後調用對應的Unicode API。如果一個字串被返回,作業系統還要把它轉變回去。儘管這個轉換過程被高度最佳化了,但它對速度造成的損失是無法避免的。
只要你使用Unicode API,NT系統允許使用非常長的檔案名稱(突破了MAX_PATH的限制,MAX_PATH=260)。使用Unicode API的另一個優點是你的程式會自動處理使用者輸入的各種語言。所以一個使用者可以輸入英文,中文或者日文,而你不需要額外編寫代碼去處理它們。
最後,隨著windows 9x產品的淡出,微軟似乎正在拋棄MBCS APIs。例如,包含兩個字串參數的SetWindowTheme() API只有Unicode版本的。使用Unicode來build你的程式將會簡化字串的處理,你不必在MBCS和Unicdoe之間相互轉換。
即使你現在不使用Unicode來build你的程式,你也應該使用TCHAR及其相關的宏。這樣做不僅可以的代碼可以很好地處理DBCS,而且如果將來你想用Unicode來build你的程式,你只需要改變一下前置處理器中的設定就可以實現了。