C++的各种字符串和常见问题

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 因为操作习惯的原因,尽量少用。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

*