Windows C++ 客户端开发中,字符串操作是很常见的。但是字符串类型很多,而且很多坑,工作中发现很多跟字符串相关的BUG。本文总结了一些常用字符串的优缺点,还有坑。
一、陈旧的字符数组
C\C++语言中并没有提供字符串这一个基础类型,所有对于字符串的操作都是使用的字符串的数组。stl 和 atl 中 string 和 CString 也是封装的字符串数组。
1、常见使用
TCHAR szFilePath[20] = { 0 }; TCHAR szDir[] = _T("c:\\"); TCHAR szFilName[] = _T("Test.txt"); _tcscpy_s(szFilePath, _countof(szFilePath), szDir); // 复制 _tcsncat_s(szFilePath, _countof(szFilePath), szFilName, _tcslen(szFilName)); // 拼接 _tcslwr_s(szFilePath); // 转换为小写 int nTmp = _tcscmp(szFilePath, szDir); // 比较 |
2、优点:
a、简单明了,完全都是自己控制的。
3、缺点:
a、相关操作API繁杂无比,部分陈旧API存在安全隐患。比如,单单字符串拷贝就有4个版本:最古老的版本 strcpy,安全系列的 strcpy_s,Windows Shell的 StrCpy,微软自己的 StringCbCopy。其中最古老的 strcpy 存在安全问题,缓冲区溢出漏洞最经常拿来举例的就是这个API,就是下面的例子
void TestFunc(LPCTSTR lpszInPath) { TCHAR szBuf[10]; _tcscpy(szBuf, lpszInPath); } |
当传入的参数长度大于10的时候,就会造成栈溢出。不过这个API在VS2015中默认情况下会被报错禁止使用。建议在使用这个基础字符串的时候尽量使用 _s 版本的API。
b、手工控制长度,容易失误
void TestFunc(LPCTSTR lpszInPath) { TCHAR *pBuf = NULL; ATLTRY(pBuf = new TCHAR[10]); if (pBuf) { _tcscpy_s(pBuf, 30, lpszInPath); delete[] pBuf; pBuf = NULL; } } |
上面的例子,长度很容易看到填错了,但是实际写代码时,多个API的其他调用,结合字符与字节的差异,很容易出问题。
c、手工控制资源申请与释放,容易资源泄漏
void TestFunc(LPCTSTR lpszInPath) { TCHAR *pBuf = NULL; ATLTRY(pBuf = new TCHAR[10]); if (pBuf) { if (TRUE) // 某个条件分支 { return; } _tcscpy_s(pBuf, 30, lpszInPath); delete[] pBuf; pBuf = NULL; } } |
二、正统的 std::string
标准库里面的字符串类,正统,嫡系,很多开源库里面都是这个。
1、常见使用
std::string strPath; std::string strDir("c:\\"); std::string strFileName("Test.txt"); strPath = strDir + strFileName; // 复制与拼接 std::transform(strPath.begin(), strPath.end(), strPath.begin(), ::tolower); // 最小化 int nTmp = strPath.compare(strDir); // 比较 |
2、优点:
a、使用简单,不再需要考虑内存的申请与释放,以及字符串的长度。
3、缺点:
a、部分操作还是不方便,比如最小化要记住一个长长的函数和参数,嗯,我刚才也是抄的。
三、推荐使用的 CString
微软在 MFC 和 ATL 库里面都有这么一个 CString,有时名字也叫做 CAtlString。
1、常见使用
CString strPath; CString strDir(_T("c:\\")); CString strFileName(_T("Test.txt")); strPath = strDir + strFileName; // 复制与拼接 strPath.MakeLower(); // 最小化 int nTmp = strPath.CompareNoCase(strDir); // 比较 |
2、优点
a、使用方便,能想到的操作几乎都提供了简便的调用,比如最小化、最大化、比较、字符串分割等。
3、缺点:
a、非C++嫡系,很多类库都是用的 stl::string,用那些库的时候就不能用 CString 了。
4、其他:
a,这么好用的一个类,看起来很大的样子,会不会占用的内存空间比较大?CString的成员变量是一个 PXSTR m_pszData,但是对于这个对象其实还关联了一个 CStringData。
struct CStringData { IAtlStringMgr* pStringMgr; int nDataLength; int nAllocLength; long nRefs; } |
所以他的大小其实比常规使用字符串多了16个字节(在指针、int、long均为4个字节的场景下)。但是这个 CStringData 带给了我们在某些场景上的便利,比如在下面这样拷贝字符串
CString strPath; CString strDir("c:\\"); strPath = strDir; // 复制与拼接 |
其实只是拷贝了指针,并未实际进行拷贝字符串的内容,性能比字符串拷贝更好一些。
b,有些系统API要求一个字符串数组,这时怎么处理?使用 GetBuffer 或者 GetBufferSetLength 拿到内部的字符串指针传给API,后面记得 ReleaseBuffer
CString strPath; GetCurrentDirectory(MAX_PATH, strPath.GetBufferSetLength(MAX_PATH+1)); strPath.ReleaseBuffer(); |
c、如何分割字符串
CAtlString strIds(_T("1;2;;3")); CAtlString strToken(_T(";")); std::vector<CAtlString> vecIds; int nPos = 0; CString strId = strIds.Tokenize(_T(";"), nPos); while (-1 != nPos) { vecIds.push_back(strId); strId = strIds.Tokenize(_T(";"), nPos); }; |
四、有些场景要用的 BSTR 和 CComBSTR
COM接口的设计是跨语言的,所以他也设计了自己的字符串类型:OLECHAR 和 BSTR。对于 OLECHAR,可以认为是 TCHAR 一样的类型,在不同的场景表示不同的类型,OLECHAR 在 32 位平台就是 WAHCR
typedef WCHAR OLECHAR; |
BSTR,指向了字符串数组,但是他其实是一个一个指定长度的字符串。在指针前面还有4个字节用于表示他的长度。
typedef _Null_terminated_ OLECHAR* BSTR; |
虽然 BSTR 是一个不同的类型,但是对于编译器来说,他就是个 WCHAR*,所以使用时,要注意各种类型问题。CComBSTR 是对BSTR的封装。
1、常见使用
BSTR 的申请与释放都需要用专门的API,SysAllocString 和 SysFreeString,不过裸用总是最危险的,还是看 CComBSTR 的使用吧:
CComBSTR bstrPath; CComBSTR bstrDir(OLESTR("c:\\")); CComBSTR bstrFileName(OLESTR("Test.txt")); bstrPath = bstrDir; // 复制 bstrPath.AppendBSTR(bstrFileName); // 拼接BSTR的时候需要明确指定,因为BSTR内部可以包含NULL字符 bstrPath.ToLower(); // 最小化 bool bSame = (bstrPath == bstrDir); // 比较 |
虽然 CComBSTR 也提供了比较和拼接等基础操作。但是常见的使用还是在接口中的传递。
HRESULT GetName(BSTR* pbstrName) { CAtlString strName; // 对 strName 的各种操作 strName = "test"; CComBSTR bstrName(strName); *pbstrName = bstrName.Detach(); return S_OK; } ////////////////////// { CAtlString strName; { CComBSTR bstrName; GetName(&bstrName); strName = bstrName; } } |
因为 BSTR 设计就是跨语言使用的,所以跨模块传递也是没有问题的。从实现来来说, SysAllocString 是在进程堆里面申请的内存,所以支持同进程内跨模块的释放。另外,std::string 使用的 CRT 堆,CString 也是用的进程堆,不过不建议用 CString 作为跨模块接口中的类型来使用。
2、优点
a、因为是在进程堆里面申请的内存,所以可以安全的跨进程使用。
b、相比于系统的 GetSystemDirectory 这种API,长度由字符串本身控制,无需外部指定。不用一次长度不够时再调用一次,被调用方直接搞定了。
3、缺点
a、可以内含NULL,跟其他字符串使用不一样,忘记的话,可能导致使用时字符串被截断
b、申请与释放均需要专门的API,而且开头有字符串长度,跟普通字符串有时存在兼容性问题。比如把一个普通字符串传给一个要BSTR的接口,那个接口可能回去读取前面的长度,然后申请内存,这个就出问题了。或者把BSTR给一个普通字符串的场景,在释放上就存在问题了。
五、常见 bug
1、空指针
普通字符串使用注意判空。std::string 用空指针赋值会 crash,CString 不会。另外,空的 std::string 和 CString 返回字符串不是返回的 NULL 指针,而是一个指向只有一个空字符的指针。
2、字符与字节区别
定义 unicode 宏时,一个 TCHAR 的长度是2个字节。如果搞混的话,很容易导致堆破坏。比如下面的代码
LPCTSTR lpszSrc = NULL; TCHAR *pDstSrc = NULL; // ........ int nLen = (int)_tcslen(lpszSrc); nLen += 16; pDstSrc = (TCHAR *)malloc(nLen); |
3、多线程
字符串的都不是多线程安全的,在多线程读没有问题,涉及到写是不安全的。实际编码中要注意自己所在的线程环境,并不只是自己开的线程是多线程,很多回调函数也可能是多线程的。
class ICallBack { public: virtual void OnPath(LPCTSTR lpszPath) = 0; }; class CTest : public ICallBack { public: void OnPath(LPCTSTR lpszPath) { m_strPath = lpszPath; } private: CAtlString m_strPath; }; |
4,字符串末尾的 null 字符
很多场景的API都是要求字符串末尾以 null 结尾。特别需要注意一些读取文件后,给第三方库来解析的场景,遇到过多个没有末尾加 null 字符串,导致第三方库读越界的情况。
Json::Value aRoot; Json::Reader aJsonReader; char *pFileBuf = NULL; int nFileSize = 0; // 获取文件大小 赋值 nFileSize ATLTRY(pFileBuf = new char[nFileSize]); if (NULL != pFileBuf) { // 从文件中读取到 pFileBuf .... aJsonReader.parse(pFileBuf, aRoot); } |
另外,有些 API 是要求两个null 字符的,比如 RegSetValueExA
For string-based types, such as REG_SZ, the string must be null-terminated. With the REG_MULTI_SZ data type, the string must be terminated with two null characters.
5,字符串生命周期
不只是裸用字符串的似乎,用 new 申请的字符串,还有一些其他的情况,比如
CAtlString GetXXXPathFunc() { CAtlString strRet; // ..... return strRet; }; int main() { LPCTSTR lpszTest = GetXXXPathFunc(); CreateFile(lpszTest, .....) } |
6,长度
见过多例未判断源字符串长度导致的访问越界
CAtlString strUrl; // ... TCHAR szUrl[MAX_PATH] = { 0 }; memcpy(szUrl, (LPCTSTR)strUrl, MAX_PATH); |
以上的坑看起来都挺简单的,但每条都是在实际工作中遇到的,均引发过 crash 的之类的外发BUG,尤其有的导致了堆破坏,耗费了很多人力去查。估计因为开发经常拷贝粘贴代码,有些从外界拷贝的代码质量不不高,而且开发在预估开发时间时,经常预估的时间少很多,导致开发时间紧,压力一大,不仔细检查拷贝的代码,就容易出问题了。
另外,还遇到过一些未解之谜,比如曾发现过 CString MakeLower 和 Format 的 Crash,但最后也没找到原因,而且逐渐消失了。
六、建议
尽量多使用 CString, 注意字符串的生命周期、长度、末尾的 null 字符串,以及多线程操作问题。尽量不要自己手工操作字符数组,一定要用的话,还要注意选用的 api 问题。.std::string,按场景使用。BSTR 因为操作习惯的原因,尽量少用。