C++字符串完全指南 第一部分:Win32字符编码
前言
毫无疑问,你见过多种不同的字符串类型,比如TCHAR,std::string,BSTR等等。另外还有一些以_tcs打头的宏也很让人很难理解。当你看见屏幕上的东西时,你禁不住想,哇,都是什么东西?本文将概述每一种字符串类型的特性;给出一些简单的示例并且概括如何在需要的时候将一种字符串类型转换成其它的类型。
在第一部分,我将会讨论三种字符编码。这对于你理解编码的工作方式是很重要的。尽管你已经知道一个字符串是一系列字符的组合,这部分还是很重要的。当你了解了这一部分,你就会更加清楚不同字符串类型之间的联系。
在第二部分中,我将会讨论字符串类本身,什么时候使用什么字符串并且如何在他们之间转换。
字符基础知识 -ASCII,DBCS,Unicode
所有的字符串类型都归结为C风格的字符串。C风格的字符串是一系列字符的组合。所以我会首先讨论字符类型。通常有三种编码方案和三种字符类型。
第一种编码方案是单字节字符集(single-byte character set),或者叫SBCS。在这种编码方案中,所有的字符都是一个字节长。ASCII是SBCS的一个特例。一个字符0标志着一个SBCS字符串的结束。
第二种编码方案是多字节字符集(multi-byte character set),或者叫MBCS。 多字节编码方案包含了单字节的字符和多于一个字节的字符。在Windows系统中使用的多字节字符集有两种字符类型,单字节字符和双字节字符。因为Windows中使用的多字节字符集最大的字符是双字节的,所以我们通常用双字节字符集(double-byte character set),或者叫DBCS来代替MBCS。
在双字节编码方案中,某些值被保留起来说明他们是一个双字节字符的一部分。例如,在Shift-JIS编码方案中(通行的日语编码方案),位于0x81-0x
9F
和0xE0-0xFC之间的字符意味着:这是一个双字节字符,第二个字节是这个字符的一部分。这些值叫头字节(lead byte),它们的值通常大于0x
7F
。在头字节后面的字节叫尾字节(trail byte)。在双字节编码方案中,尾字节可以是任何一个非零值。就像在单字节编码方案中一样,双字节编码方案也是以字符0结束的。
第三种编码方案是Unicode。Unicode是一种所有字符都是两个字节长的编码方案。Unicode字符有时被叫做宽字符,因为它们比单字节字符更宽(占用更多存储空间)。要注意到Unicode不能被考虑成一种多字节字符编码方案。一个明显的特征是Unicode字符的长度和MBCS的长度不一样。Unicode字符串都是以两个字符0来结束的。
单字节字符是由拉丁字母,音节符号和一些在ASCII标准和DOS系统中定义的图形符号组成的。双字节字符用于东亚和中东语言。Unicode用于COM组件和Windows NT内部。
你肯定已经很熟悉单字节字符了。当你使用char数据类型的时候,你就在使用单字节字符。你也可以使用char来处理双字节字符(这也是你使用双字节字符所遇到的奇怪现象之一)。Unicode字符通常使用wchar_t类型。Unicode字符和字符串常量通常以字母L打头。例如:
![](https://i-blog.csdnimg.cn/blog_migrate/f0cd6c7f9e7ae96feae062cb48f670f0.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/f0cd6c7f9e7ae96feae062cb48f670f0.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/f0cd6c7f9e7ae96feae062cb48f670f0.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/f0cd6c7f9e7ae96feae062cb48f670f0.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/f0cd6c7f9e7ae96feae062cb48f670f0.gif)
字符在内存中是如何存储的?
单字节字符在内存中都是一个接一个连续存储的,用一个0来结束字符串。例如,"Bob"是这样存储的
42 6F 62 00
B o b EOS
如果是Unicode版本,L"Bob"是这样存储的
42 00 6F 00 62 00 00 00
B o b EOS
使用字符0x0000来结束这个字符串
双字节字符第一眼看上去很象单字节字符串。但是我们随后会看到在使用函数操作字符串和使用指针偏历字符串的时候还是有所不同。字符串"" ("nihongo")是按照下面的方式存储的。(头字节和尾字节用LB和TB来表示)
93 FA 96 7B 8C EA 00
LB TB LB TB LB TB EOS
日 本 EOS
需要注意的是,“ni”并不等于0xFA93。两个值93和FA,以上面的顺序组合在一起,用来编码字符“ni”(在Big-Endian的CPU中,字节存储的方式就是象上图那样)
使用字符串处理函数
我们都见过象strcpy(),sprintf(),atol(),等等这些字符串函数。这些函数只能用于单字节字符串。标准库也提供了用于操作Unicode字符串的函数,比如wcscpy(),swprintf(),_wtol()等。
微软也在CRT(C运行库)中增加了操作双字节字符串的函数。比如strxxx()的函数都有对应的_mbsxxx()函数,以用来操作双字节字符串。如果你预料到你的程序会处理双字节字符串(比如你的程序会安装在日文,中文,或者其它使用双字节字符串的语言中),那么你就应该始终用_mbsxxx()函数。它们也可以接受单字节字符串(因为双字节字符串能包含单字节字符,所以_mbsxxx()函数可以处理单字节字符串)
我们可以举一个典型的例子来显示不同函数的用途。还是回到Unicode字符串L"Bob"。
42 00 6F 00 62 00 00 00
B o b EOS
因为X86架构的CPU都是little-endian的。值0x0042在内存中存储方式是42 00。 你能发现如果我们把这个字符串传入到strlen()中发生的问题吗?它将会首先看到第一个字节42,然后是00,这意味着“字符串结束”。那么strlen()将会返回1。如果我们把它传入wcslen(),情况会变得更坏,wcslen()将会看见0x
6F
42,然后是0x0062,然后会一直读取下去直到超过缓冲区大小,直到偶然碰到0000或者引起一个GPF错误。
我们已经讨论了strxxx()和wcsxxx()的使用,那么strxxx()和_mbsxxx()有什么不同吗?它们之间的区别是很重要的。这也关系到正确地遍历双字节字符串。接下来我会讨论遍历字符串然后回到比较strxxx()和_mbsxxx()上面
正确地遍历和索引字符串
因为我们中的大部分人都习惯了使用单字节字符串,我们习惯于对一个指针使用++和--操作来遍历一个字符串。我们也使用数组的方式来访问字符串中的任何字符。这些方法能够在单字节字符串和Unicode字符串下面工作的很好。因为所有的字符都有相同的长度并且编译器可以正确地返回我们需要的字符。
然而,你在处理双字节字符串的时候必须打破这些习惯。在遍历双字节字符串的时候有两个规则,如果打破了这两个规则,将会引起几乎所有的双字节函数的bug。
1,别用++向前遍历字符串,除非你用这种方式来检查头字节
2,永远不要使用--来向后遍历字符串
我将首先说明规则2,因为很容易就能找到打破了这个规则的代码。如果你有一个程序并且保存了一个config文件在程序目录下,并且把安装目录保存在注册表中。在运行的时候,你读取安装目录,得到配置文件的名字,并且试图去读取它。比如你安装到了c:/program files/mycoolapp,配置文件是c:/program files/mycoolapp/config.bin。
现在,假设你用下面的代码来构造文件名:
这段代码在大多数情况下工作的不错,但是对于某些特殊的双字节字符会失效。假设有一个日本用户将你的程序的安装目录改成了C:/
,这个目录在内存中是如下保存的
{
char szConfigFilename[MAX_PATH];
// 从注册表中读取安装目录,假设它是正常的
// 如果注册表中没有包含/,就添加一个/
// 首先,得到一个指向'/0'的指针
char * pLastChar = strchr ( szConfigFilename, ' /0 ' );
// 向后退一个字符
pLastChar -- ;
if ( * pLastChar != ' // ' )
strcat(szConfigFilename, " // " );
// 添加配置文件的名字
strcat(szConfigFilename, " config.bin " );
// 如果调用的缓冲区足够大,返回文件名
if ( strlen ( szConfigFilename ) >= nBuffSize )
return false ;
else
{
strcpy ( pszName, szConfigFilename );
return true ;
}
}
43 3A 5C 83 88 83 45 83 52 83 5C 00
LB TB LB TB LB TB LB TB
C : /
EOS
当GetConfigFileName()检查尾部的/时,它会去检查安装目录的最后一个非零字符,如果等于'//',那么就不再添加一个/。对于上面的这种情况,代码最后会返回一个错误的文件名。
到底是什么出了问题?请注意上面以蓝色高亮的字节。’/’的值是0X
5C
,‘'的值是83
5C
,上面的代码错误地把尾字节当成了一个独立的字符。
正确的方法是使用函数来处理双字节字符串向后移动的问题。函数可以正确地知道字节的数量从而移动到正确的位置。下面是正确的代码,移动指针的部分以红色标出来了。
修正后的函数使用了CharPrev()这API来把pLastChar向后移动一个字符。如果字符串以一个双字节字符结尾的话它会移动两个字节,这这个函数中,if处的代码能正常地工作,因为首字节永远不会等于0X 5C 。
{
char szConfigFilename[MAX_PATH];
// 从注册表中读取安装目录,假设它是正常的
// 如果注册表中没有包含/,就添加一个/
// 首先,得到一个指向'/0'的指针
char* pLastChar = _mbschr ( szConfigFilename, '/0' );
// 向后移动一个双字节字符
pLastChar = CharPrev ( szConfigFilename, pLastChar );
if ( *pLastChar != '//' )
_mbscat ( szConfigFilename, "//" );
// 添加配置文件的名字
_mbscat ( szConfigFilename, "config.bin" );
// 如果调用的缓冲区足够大,返回文件名
if ( _mbslen ( szInstallDir ) >= nBuffSize )
return false;
else
{
_mbscpy ( pszName, szConfigFilename );
return true;
}
}
你现在可能已经想到了打破条款1的工作方式。比如,你可以想验证一个由用户输入的文件名,验证的方式是你检查':'的数量,如果你使用++来遍历字符串,而不是使用CharNext(),当文件名有尾字节等于':'的字符时,函数就会出错了。
和规则2相关的还有一个关于使用数组索引的情况
2a ,永远不要使用减法来计算字符串中的索引
违反了这条规则出错的情况和违反规则2的情况很相似。例如,如果pLastChar按照下面的方式设置它的值。
char *pLastChar = &szConfigFilename[strlen(szConfigFilename)-1];
这个会出现和上面一样的问题,因为它违反了规则2。
回到strxxx()和_mbsxxx()
现在大概已经清楚了为什么_mbsxxx()函数是必需的。strxxx()函数不能处理双字节字符,但是_mbsxxx()可以。如果你调用strrchr("C://
",'//'),返回的值将会是错误的,然而_mbsrchr()能识别尾端的双字节字符从而返回一个指向实际的’/’的指针。
关于字符串函数的最后一点是,strxxx()函数和_mbsxxx()函数接受或者返回的都是以char来度量的长度。如果一个字符串包含3个双字节字符,_mbslen()将会返回6,Unicode函数返回的是wchar_t来度量的长度,比如,wcslen(L"Bob")将会返回3。
Win32API中的多字节字符集和Unicode
两套API
尽管你可能从来没有注意过,Win32中的每个处理字符串的API和消息都有两个版本,一个版本接受MCBS字符串,另外一个版本接受Unicode字符串。例如,API中没有叫SetWindowText()API,相反的,有两个函数,一个叫SetWindowTextA(),另外一个叫SetWindowTextW(),后缀A(对应ANSI)表示是MBCS函数,后缀W表示是Unicode函数。
当你构建一个Windows程序的时候,你可以选择使用MBCS函数或者Unicode函数。如果你使用过VC的AppWizards并且从来没有更改过预处理设置,你使用的就是MBCS函数。为什么我们可以写“SetWindowText"但是实际中又没有这个API呢?winuser.h包含了一些#define定义,就像下面的那样
当使用MBCS API的时候,没有定义UNICODE,那么预处理就是:
BOOL WINAPI SetWindowTextW(HWND hwnd, LPCWSTR lpString);
#ifdef UNICODE
#define SetWindowText SetWindowTextW
#else
#define SetWindowText SetWindowTextA
#endif
#define SetWindowText SetWindowTextA
这个时候调用SetWindowText()就会去调用真正API,SetWindowTextA()(要注意的是你可以直接去调用SetWindowTextA()或者SetWindowTextW(),尽管我们很少这样去做)。
如果你想缺省就调用Unicode的API,那么你可以到预处理部分并且从预定义标识列表里面去掉_MBCS标识,并且加入UNICODE和_UNICODE(你应该把两个标识都加上,因为不同的头文件使用了不同的标识),然而,如果你直接使用char来定义你的字符串,你会遇到一些麻烦,考虑下面的代码
在编译器用"SetWindowTextW"代替了"SetWindowText"后,代码变成了
char szNewText[] = " We love Bob! " ;
SetWindowText(hwnd, szNewText);
char szNewText[] = " We love Bob! " ;
SetWindowTextW(hwnd, szNewText);
看到问题所在了吗?我们正在向一个接受Unicode字符串的函数传递单字节字符串。第一个解决方案是使用#ifdef来处理字符串变量
#ifdef UNICODE
wchar_t szNewText[] = L " we love Bob! " ;
#else
char szNewText[] = " we love Bob! " ;
#endif
SetWindowText ( hwnd, szNewText );
如果在你的代码中,每个地方都要这样处理,你肯定很头大。正确的方式是使用 TCHAR.
TCHAR是补救措施
TCHAR是一种可以让你对MBCS和Unicode都使用同一份build的字符类型。你的代码中并不需要任何的#define
对于TCHAR的定义象这样:
typedef wchar_t TCHAR;
#else
typedef char TCHAR;
#endif
所以在MBCS版本中TCHAR就是char,在Unicode版本中它就是wchar_t。有一个_T()的宏可以用来处理Unicode字符串需要的L前缀
#define _T(x) L##x
#else
#define _T(x) x
#endif
## 是一个预处理操作符,它可以把两个参数连接在一起。无论你什么时候使用字符串常量,都使用 _T 宏,它会给你的 Unicode 版本字符串加上 L 前缀。
TCHAR szNewText[] = _T("we love Bob!");
就像有宏来隐藏SetWindowTextA/W的处理一样,也有宏用来自动处理strxxx()函数和_mbsxxx()函数。例如,你可以使用_tcsrchr宏在使用strrchr()或者_mbsrchr()或者wcsrchr()的地方。_tcsrchr根据你是否定义了_MBCS或者UNICODE来决定使用哪一个函数,就像SetWindowText那样
并不是只有strxxx()这样的函数才有TCHAR宏,也有其它的一些,比如_stprintf(用来替换sprintf()和swprintf()),以及_tfopen(用来替换fopen()和_wfopen),完整的宏列表可以在MSDN中的“Generic-Text Routine Mappings"中查到
字符串和TCHAR类型定义
因为Win32 API文档中列举函数都是用通用的名字,比如"SetWindowText"。所有的字符串都是以TCHAR的形式给出的。()例外的是在XP中引入的只适用于Unicode的函数。下面是你在MSDN中看到的常用的类型定义
类型 | 在MBCS版本中的含义 | 在UNICODE版本中的含义 |
WCHAR | char | wchar_t |
LPSTR | 以0结尾的char字符串(char*) | 以0结尾的char字符串(char*) |
LPCSTR | 以0结尾的常量char字符串(const char*) | 以0结尾的常量char字符串(const char*) |
LPWSTR | 以0结尾的Unicode字符串(wchar_t*) | 以0结尾的Unicode字符串(wchar_t*) |
LPCWSTR | 以0结尾的常量Unicode字符串(const wchar_t*) | 以0结尾的常量Unicode字符串(const wchar_t*) |
TCHAR | char | wchar_t |
LPTSTR | 以0结尾的TCHAR字符串(TCHAR*) | 以0结尾的TCHAR字符串(TCHAR*) |
LPCTSTR | 以0结尾的常量TCHAR字符串(const TCHAR*) | 以0结尾的常量TCHAR字符串(const TCHAR*) |
什么时候使用TCHAR和Unicode
在讨论了这么多以后,你可以会疑惑,我使用char已经很多年了,为什么我要使用Unicode?在下面三种情况下Unicode版本是很有好处的
1,你的程序只在Windows NT上面运行
2,你的程序需要处理超过MAX_PATH限制的长文件名
3,你的程序使用了Windows XP中新引进的API,它们没有A/W之分。
Unicode 的API大多数都不能在Windows 9X上面运行。如果你想在9x上面运行你的程序,你只能使用MBCS API(微软发布了一个较新的叫Microsoft Layer for Unicode的类库可以让你在9x上面使用Unicode API,但是我没有使用过这个类库,所以不加评论)。然而,因为NT内部全部是Unicode的处理方式,所以你用UnicodeAPI会加快程序运行的速度。每次你传递一个字符串给MBCS API,操作系统就会把它转化成Unicode并且调用相应的Unicode函数。如果字符串被返回,操作系统还要把它转换回来。尽管这些转换已经被高度优化过并且让它们对效率的影响尽可能小,但是它们还是可以被避免的。
NT运行使用非常长的文件名(超过了通常的MAX_PATH定义的260字符限制),但是只有你使用Unicode API才可以。还有一个好处是使用Unicode API,你的程序可以自动处理用户的输入。因此如果用户输入英文,中文,日文混杂的文件名,你也不需要特别的代码来处理。它们都是Unicode字符。
最后,随着Window 9x的逐渐远去,微软对于MBCSAPI的支持也在慢慢减弱。例如,SetWindowThemes()函数可以接受两个字符串参数,但是只接受Unicode参数。使用Unicode版本可以简化字符串处理过程,因为你不用从MBCS转化成Unicode,再转化回来。
即使你现在不打算使用Unicode来构建你的软件,你也应该总是使用TCHAR和相关的宏。这样不仅可以确保你的双字节函数可以正常工作,而且如果当你决定使用Unicode版本的时候,你只需要改变预处理设置即可。
本文是翻译文章,原文链接在:http://www.codeproject.com/string/CPPStringGuide1.asp
/******************************************************************************************
*【Author】:flyingbread
*【Date】:2006年12月23日
*【Notice】:
*1、本文为原创技术文章,首发博客园个人站点(http://flyingbread.cnblogs.com/),转载和引用请注明作者及出处。
*2、本文必须全文转载和引用,任何组织和个人未授权不能修改任何内容,并且未授权不可用于商业。
*3、本声明为文章一部分,转载和引用必须包括在原文中。
******************************************************************************************/