Articles

C、C++、Win32、MFC字符串操作对比

在Windows下用C++进行开发的程序员可能天天都会与字符串打交道。由于字符串函数使用不当导致的程序编译不过、运行时崩溃的问题也屡见不鲜。经常有人问我,为什么MSDN上找不到CString类,而只有CStringT类?为什么找不到string类,而只有basic_string类?char *stringCString之间如何正确转换?再加上LPCTSTR之类的都是些什么类型?_T又是什么?如果你也对这些问题感到困惑,欢迎继续往下阅读,我们一起对这些问题进行深入的分析。

约定

首先对本文经常提到的几个概念进行定义,以使大家在用语上达成共识。

多字节:Multi Bytes Character String(MBCS),可以是ASCII字符串,也可以是UTF-8字符串。多字节这种叫法正是由于UTF-8的特点而来的。

宽字符:Wide Char,表示UCS2字符集(如Windows系统中所采用的方法)或UCS4字符集(如Linux系统中所采用的方法)。字符串中每一个字符都是等宽的2或4字节表示。

C运行库:C Runtime,也简称CRT。 Win32:Win32 API的简写。

本文不对Unicode、UTF-8编码等问题进行详细介绍,但这些概念是讨论字符串操作函数的基础。关于Windows中Unicode相关内容可以参阅《Programming Windows》一书的第二章。

C

相信大家对C语言的字符串操作一定都不会陌生,无论你用的是Borland C++ Builder、Microsoft Visual C++,还是GNU Compiler Collection,或者嵌入式,这些函数和用法几乎都是一样的。下表中列举了几个常见的字符串操作函数:

多字节宽字符
指针类型char * wchar_t *
const指针类型 const char * const wchar_t *
字符串拷贝strcpywcscpy
字符串长度strlenwcslen
字符串连接strcatwcscat
字符串比较strcmpwcscmp
字符串搜索strstrwcsstr

上面这些函数大家在学校里基本都学过,或者在工作中也都用到过。从表中可以看出,简单的记忆一个函数是多字节还是宽字符的方法是:多字节字符串操作函数以str开头,而宽字符字符串操作函数以wcs开头。这些函数都是由C语言运行库提供的。如果对C语言的这些函数有疑问,可以查阅《The C Programming Language》一书。

C++

C++除了包含C的全部内容外,还提供了STL模板库。C++用string类重新实现了几乎C语言中全部的字符串操作函数。C语言提供的这些函数直接操作内存地址,很容出现字符串拷贝越界、分配的字符串空间忘记释放导致内存泄露等问题,最终造成程序运行异常。C语言的这一缺点也就成了C++的优点。C++提供的string类对底层指针进行封装,外界不会直接操作底层指针,从而规避了内存越界访问、内存泄露等问题。我们同样以C语言中常用的几个函数为例,看看C++是如何进行这些操作的。

C语言多字节C++ string / wstring
字符串拷贝strcpyoperator =
字符串长度strlenlength
字符串连接strcatoperator +
字符串比较 str operator ==, operator !=
字符串搜索 strstr find, find_first_of
取子字符串N/Asubstr
字符串替换N/Areplace

string类不仅能完成C语言提供的字符串操作函数的功能,而且使用起来更为方便,并且提供了C语言本身没有的功能。这里有一个疑问,对于多字节string类和宽字符的wstring类,C++会分别实现这两个类吗?这两个类除了所操作的变量类型不同外,代码是100%相同的,有什么办法可以把这两个类的实现统一起来呢?C++用模板机制,完成了上述两个类实现的统一。C++实现了basic_string模板类,来完成所有字符串的操作,并且根据模板参数是char还是wchar_t来实例化不同类。而string和wstring只不过是用typedef定义出来的两个新类型罢了。

typedef basic_string<char>    string;
typedef basic_string<wchar_t> wstring;

(注:上述代码对真正的代码进行了一定的简化,只保留了本文关心的内容。)

这就是为什么在MSDN里面找不到string和wstring,而只有basic_string。

Win32

在Windows中开发应用程序,可以使用VC/VB等多种语言,不仅限于C/C++,甚至可以直接使用汇编语言。所以Win32 API本身必须还得提供一些基础的函数库,例如字符串操作函数。并且,微软按照自己的变量命名风格,定义了一套新的数据类型。以下是Win32 API中字符串操作函数和变量类型列表,仍然以最常见的几个字符串操作函数为例:

多字节宽字符
指针类型LPSTRLPWSTR
const指针类型LPCSTRLPCWSTR
字符串拷贝 StringCbCopyA StringCbCopyW
字符串长度 StringCbLengthA StringCbLengthW
字符串连接 StringCbCatA StringCbCatW
字符串比较 CompareStringA CompareStringW
字符串搜索N/AN/A

从表中可以看出,Win32 API定义的变量类型都是大写的,例如LPSTR,以及我们常见的TCHAR、BOOL等。由于历史原因,Win32 API中还到处充斥着LP(Long Pointer)这样的表示长指针的类型修饰。LP本来是用在16位操作系统中,表示32位长指针的。虽然我们现在用的电脑已经都是32位了,但这一修饰还是被保留下来了。LPSTR表示指向字符串的长指针,LPCSTR表示指向字符串的const长指针,只要大家找到微软定义这些类型的规律,就很容易记住这些类型。对于上面这些函数,简单记忆的方法是,多字节函数以A结尾,简记为A。宽字符函数以W结尾,简记为W。相信除了微软自己,应该很少有人用微软提供的这些字符串操作API。使用这些函数只会让代码更不具备可移植性。但在某些特殊场合,例如想在Windows平台实现字符串操作,但又不想使编译出来的程序依赖CRT,又想使编译出来的程序体积尽可能的小,那么就可以考虑调用Win32 API来实现。

MFC

从Win32 API到MFC的进化,如同从C到C++的进化一样。MFC用类的思想,重新将底层Win32 API进行封装,进而出现了我们非常熟悉的CString类。CString不但比Win32 API提供的字符串处理函数功能更强大,使用起来也非常的方便。其方便程度在某些方面甚至超过了C++的string。所以好多人喜欢用CString。以下是string和CString类的对比:

C++ string / wstringCString
字符串拷贝operator =operator =
字符串长度lengthGetLength
字符串连接operator +operator +
字符串比较 operator ==, operator != operator ==, operator !=
字符串搜索 find, find_first_of Find, FindOneOf
取子字符串substrLeft, Mid, Right
字符串替换replaceReplace

从表中可以看出,MFC的CString与C++的string类非常相似。所以,这里再次提出讲string时提到的那个问题,CString类是如何支持多字节和宽字符的呢?为什么MSDN里面也查不到CString类呢?不同VC版本解决这个问题的方法不太一致。VC6的CString实现是直接将LPTSTR作为内部保存成员变量的类型,所以可以根据UNICODE宏定义来编译出多字节和宽字符的版本。对于VC2008,CString使用了与string类似的C++的模板机制。

typedef CStringT< TCHAR, StrTraitATL< TCHAR > > CString;
template< typename BaseType, class StringTraits >
class CStringT : public CSimpleStringT< BaseType,  >

(注:上述代码对真正的代码进行了一定的简化,只保留了本文关心的内容。)

在VC2008中,CString只是typedef定义出来的一个类型,真正的实现类是CStringT和CStringT的基类CSimpleStringT。所以,如果我们电脑里安装的MSDN是较新版本的,那么你会发现已经找不到CString类了。如果我们想查找CString类的某些方法,需要分别去CStringT和CSimpleStringT里面查找。由于不同VC版本的MFC类实现方式不同,所以基于不同版本VC开发出来的模块间传递MFC对象可能会导致意想不到的结果。不同实现中,CString类的大小可能都是不同的。

C、C++、Win32、MFC间的转换

那我们应该用string还是CString?能否统一起来只用一种?如果不能统一的话又应该如何转换呢?首先,string具有跨编译器、跨平台的特性,所以底层模块为了做到更通用,往往没有必要使用MFC,自然也就不能使用CString了。而有些模块本身提供了界面,使用了MFC,所以使用了CString也就不足为奇了。

C和C++间的转换比较简单,可以用如下方式转换:

const char *p1 = "Hello World!";
string s1(p1);                  // C   -> C++
const char *p2 = s1.c_str();    // C++ -> C

C和Win32 API间字符串变量类型其实不需要转换,因为Win32 API的变量类型都是使用C的变量类型来定义的。如果两个类型本质是相同的,即使我们没有显式转换,编译器也会自动为我们进行隐式转换。如:

typedef char CHAR;
typedef wchar_t WCHAR;
typedef CHAR *LPSTR, *PSTR;
typedef WCHAR *LPWSTR, *PWSTR;
typedef const CHAR *LPCSTR, *PCSTR;
typedef const WCHAR *LPCWSTR, *PCWSTR;

Win32 API和MFC间字符串变量类型的转换稍微有些麻烦,示例如下:

LPCTSTR p1 = _T("Hello World!");
CString s1(p1);             // Win32 -> MFC
LPCTSTR p2 = (LPCTSTR)s1;   // MFC -> Win32 方式(1)
LPTSTR p3 = s1.GetBuffer(); // MFC -> Win32 方式(2)
s1.ReleaseBuffer();

这里需要说明的是,从MFC的CString到Win32的转换可能存在两种情况。如果我们只是想读取字符串的内容,应该使用方式(1)。CSimpleStringT提供了operator LPCTSTR(),这一转换是比较高效的。如果我们需要对CString内部数据进行写操作,应该使用方式(2)。GetBuffer()返回一个指向CString内部的指针,该指针不是const的。

C++和MFC间的转换可以通过将二者都转换为C和Win32 API来实现:

string s1;
CString s2;

s2 = s1.c_str();    // C++ -> C (-> Win32) -> MFC
s1 = (LPCTSTR)s2;   // MFC -> Win32 (-> C) -> C++

其中编译器会帮我们进行两次隐式的转换。

国际化

这里只介绍Windows下程序的国际化,对于Linux来说,使用的大多是UTF-8编码,向下兼容ANSI字符,所以不存在这个问题(Linux同样支持UCS4编码,但不在本文讨论范围之内)。而Windows使用的则是UCS2编码,属于宽字符,不兼容ANSI字符,所以在编写国际化程序时需特别注意。微软为了解决这一问题也是煞费苦心,定义了很多宏。我们还是以C、C++、Win32、MFC的顺序,分别介绍如何编写可同时支持多字节和宽字符的代码。

对于C语言本身来说,我们之前讨论过形如strcpy、wcscpy的函数。那么,代码中如果使用了str的函数,就只能支持多字节了,如果使用了wcs的函数就只能支持宽字符。对于Win32 API存在同样的问题,使用了A就只能支持多字节,使用W就只能支持宽字符。为了使一行代码可以同时支持多字节和宽字符,微软引入了_UNICODE宏和UNICODE宏。_UNICODE宏针对C语言的函数,UNICODE宏针对Win32 API的函数。并且,微软将所有C语言和Win32 API的函数都定义了多字节和宽字符通用的宏,如下表所示:

CWin32 API
指针类型TCHARLPTSTR
const指针类型 const TCHAR LPCTSTR
字符串拷贝 ``_tcscpy`` StringCbCopy
字符串长度 ``_tcslen`` StringCbLength
字符串连接 ``_tcscat`` StringCbCat
字符串比较 ``_tcscmp`` CompareString
字符串搜索 ``_tcsstr`` N/A

总结一下就是,对于C语言,把str和wcs替换为_tcs就可以使用这些宏来同时支持多字节和宽字符。对于Win32 API,把A和*W中的A或W去掉,也就是我们平时一直在使用的不带A或W的函数名字,就是同时支持多字节和宽字符的。很意外吧,例如我们平时使用的MessageBox函数,其实是个宏定义。

#ifdef UNICODE
#define MessageBox  MessageBoxW
#else
#define MessageBox  MessageBoxA
#endif // !UNICODE

那么对于C++和MFC呢?对于C++,因为string和wstring只不过是通过basic_string定义出来的新的类型而已,所以如果我们直接使用basic_string模板,就可以同时支持多字节和宽字符了,例如:basic_string。但是这样做还是有些坏处,模板参数TCHAR使用的是Win32变量类型,不具备跨平台的特性。对于MFC的CString类来说,CString本身就是通过CStringT定义出来的类型,所以CString是可以同时支持多字节和宽字符的。

在代码中,我们还可以看到类似_T("")TEXT("")的宏。这些宏定义如下:

#define _T(x)       __T(x)
#define _TEXT(x)    __T(x)

#ifdef _UNICODE
    #define __T(x)      L ## x
#else
    #define __T(x)      x
#endif

使用_T_TEXT宏,与使用_tcs*宏定义一样,可以让我们写出的代码根据编译选项的不同编译出多字节和宽字符的版本。关于这两个宏的来历,可以参见《The Old New Thing》一书。

使用上面提到的这些宏定义,只能让我们分别编译出多字节和宽字符的版本。但是,如果我们需要在程序运行时,同时处理多字节和宽字符的字符串怎么办呢?将const char *强制转换为const wchar_t *是不是就可以在多字节和宽字符间进行转换呢?答案是否定的。

在多字节和宽字符间进行转换,更确切的说,在ANSI、UCS2、UTF-8编码间进行转换必须使用Win32 API提供的两个函数:MultiByteToWideChar和WideCharToMultiByte。这两个函数的具体用法可以参见MSDN。

string与CString对比

这里之所以再单独把string和CString拿出来对比,是因为这两个类是我们使用频率最高的。实在是不建议大家再继续使用C语言的字符串函数和Win32 API中的字符串函数,因为已经发生过太多因为memset、strcpy使用不当导致的程序崩溃问题了。我们从几个具体实例出发,来看看应该如何使用string和CString。

例1:将字符串全部转换为大写或小写

// C++
string s1("Hello World!");
std::transform(s1.begin(), s1.end(), s1.begin(), toupper);
std::transform(s1.begin(), s1.end(), s1.begin(), tolower);

// MFC
CString s2("Hello World!");
s2.MakeUpper();
s2.MakeLower();

例2:去掉字符串左边或者右边的空格

// C++
string s1("  Hello World!  ");
s1.erase(0, s1.find_first_not_of(' '));
s1.erase(s1.find_last_not_of(' ') + 1);

// MFC
CString s2("  Hello World!  ");
s2.TrimLeft();
s2.TrimRight();

使用CString来实现上面两个功能非常方便,而使用string就会稍微麻烦一点,需要与STL中的泛型算法结合才能完成任务。下面再看两个例子:

例3:按每一个数字从小到大排序

string s("1357924680");
std::sort(s.begin(), s.end());

例4:算出大于等于5的字符个数:

string s("1234567890");

int count = std::count_if(
  s.begin(), s.end(),
  std::bind2nd(std::greater_equal<char>(), '5'));

这两个例子中,CString似乎显得无能为力了,string与STL泛型算法结合显式出了优势。所以说,CString虽然提供了一些方便的方法,但是string与STL模板库的泛型算法结合,则可以实现更强大的功能。那么在实际开发过程中应该如何选择二者呢?这要具体问题具体分析了。从锻炼开发人员写出更具备可移植性代码而言,推荐使用string。例如某些用户级工具,要求提供Mac OS版本。如果我们代码里用的是CString,那几乎不存在移植的可能。如果我们所编写的代码不涉及界面,而只是算法和逻辑,那应该尽量不使用MFC的东西,应该用纯C++的类来完成。