现在的位置: 首页 > 综合 > 正文

CString的工作原理介绍及易犯错误

2013年10月14日 ⁄ 综合 ⁄ 共 5562字 ⁄ 字号 评论关闭

CString的工作原理介绍(zz) 看了很多人写的程序,包括我自己写的一些代码,发现很大的一部分bug是关于MFC类中的CString的错误用法的.出现这种错误的原因主要是对CString的实现机制不是太了解。

    CString是对于原来标准c中字符串类型的一种的包装。因为,通过很长时间的编程,我们发现,很多程序的bug多和字符串有关,典型的有:缓冲溢出、内存泄漏等。而且这些bug都是致命的,会造成系统的瘫痪。因此c++里就专门的做了一个类用来维护字符串指针。标准c++里的字符串类是string,在microsoft MFC类库中使用的是CString类。通过字符串类,可以大大的避免c中的关于字符串指针的那些问题。

这里我们简单的看看Microsoft MFC中的CString是如何实现的。当然,要看原理,直接把它的代码拿过来分析是最好的。MFC里的关于CString的类的实现大部分在strcore.cpp中。

    CString就是对一个用来存放字符串的缓冲区和对施加于这个字符串的操作封装。也就是说,CString里需要有一个用来存放字符串的缓冲区,并且有一个指针指向该缓冲区,该指针就是LPTSTR m_pchData。但是有些字符串操作会增建或减少字符串的长度,因此为了减少频繁的申请内存或者释放内存,CString会先申请一个大的内存块用来存放字符串。这样,以后当字符串长度增长时,如果增加的总长度不超过预先申请的内存块的长度,就不用再申请内存。当增加后的字符串长度超过预先申请的内存时,CString先释放原先的内存,然后再重新申请一个更大的内存块。同样的,当字符串长度减少时,也不释放多出来的内存空间。而是等到积累到一定程度时,才一次性将多余的内存释放。

还有,当使用一个CString对象a来初始化另一个CString对象b时,为了节省空间,新对象b并不分配空间,它所要做的只是将自己的指针指向对象a的那块内存空间,只有当需要修改对象a或者b中的字符串时,才会为新对象b申请内存空间,这叫做写入复制技术(CopyBeforeWrite)。

这样,仅仅通过一个指针就不能完整的描述这块内存的具体情况,需要更多的信息来描述。

首先,需要有一个变量来描述当前内存块的总的大小。
其次,需要一个变量来描述当前内存块已经使用的情况。也就是当前字符串的长度
另外,还需要一个变量来描述该内存块被其他CString引用的情况。有一个对象引用该内存块,就将该数值加一。

CString中专门定义了一个结构体来描述这些信息:
struct CStringData
{
 long nRefs;             // reference count
 int nDataLength;        // length of data (including terminator)
 int nAllocLength;       // length of allocation
 // TCHAR data[nAllocLength]

 TCHAR* data()           // TCHAR* to managed data
  { return (TCHAR*)(this+1); }
};

实际使用时,该结构体的所占用的内存块大小是不固定的,在CString内部的内存块头部,放置的是该结构体。从该内存块头部开始的sizeof(CStringData)个BYTE后才是真正的用于存放字符串的内存空间。这种结构的数据结构的申请方法是这样实现的:
pData = (CStringData*) new BYTE[sizeof(CStringData) + (nLen+1)*sizeof(TCHAR)];
pData->nAllocLength = nLen;
其中nLen是用于说明需要一次性申请的内存空间的大小的。

从代码中可以很容易的看出,如果想申请一个256个TCHAR的内存块用于存放字符串,实际申请的大小是: sizeof(CStringData)个BYTE + (nLen+1)个TCHAR

其中前面sizeof(CStringData)个BYTE是用来存放CStringData信息的。后面的nLen+1个TCHAR才是真正用来存放字符串的,多出来的一个用来存放’/0’。

   CString中所有的operations的都是针对这个缓冲区的。比如LPTSTR CString::GetBuffer(int nMinBufLength),它的实现方法是:
首先通过CString::GetData()取得CStringData对象的指针。该指针是通过存放字符串的指针m_pchData先后偏移sizeof(CStringData),从而得到了CStringData的地址。
然后根据参数nMinBufLength给定的值重新实例化一个CStringData对象,使得新的对象里的字符串缓冲长度能够满足nMinBufLength。
然后在重新设置一下新的CStringData中的一些描述值。
最后将新CStringData对象里的字符串缓冲直接返回给调用者。

这些过程用C++代码描述就是:
 if (GetData()->nRefs > 1 || nMinBufLength > GetData()->nAllocLength)
 {
  // we have to grow the buffer
  CStringData* pOldData = GetData();
  int nOldLen = GetData()->nDataLength;   // AllocBuffer will tromp it
  if (nMinBufLength < nOldLen)
   nMinBufLength = nOldLen;
  AllocBuffer(nMinBufLength);
  memcpy(m_pchData, pOldData->data(), (nOldLen+1)*sizeof(TCHAR));
  GetData()->nDataLength = nOldLen;
  CString::Release(pOldData);
 }
 ASSERT(GetData()->nRefs <= 1);

 // return a pointer to the character storage for this string
 ASSERT(m_pchData != NULL);
 return m_pchData;

很多时候,我们经常的对大批量的字符串进行互相拷贝修改等,CString 使用了CopyBeforeWrite技术。使用这种方法,当利用一个CString对象a实例化另一个对象b的时候,其实两个对象的数值是完全相同的,但是如果简单的给两个对象都申请内存的话,对于只有几个、几十个字节的字符串还没有什么,如果是一个几K甚至几M的数据量来说,是一个很大的浪费。
因此CString 在这个时候只是简单的将新对象b的字符串地址m_pchData直接指向另一个对象a的字符串地址m_pchData。所做的额外工作是将对象a的内存应用CStringData:: nRefs加一。
CString::CString(const CString& stringSrc)
{
  m_pchData = stringSrc.m_pchData;
  InterlockedIncrement(&GetData()->nRefs);
}

这样当修改对象a或对象b的字符串内容时,首先检查CStringData:: nRefs的值,如果大于一(等于一,说明只有自己一个应用该内存空间),说明该对象引用了别的对象内存或者自己的内存被别人应用,该对象首先将该应用值减一,然后将该内存交给其他的对象管理,自己重新申请一块内存,并将原来内存的内容拷贝过来。

其实现的简单代码是:
void CString::CopyBeforeWrite()
{
 if (GetData()->nRefs > 1)
 {
  CStringData* pData = GetData();
  Release();
  AllocBuffer(pData->nDataLength);
memcpy(m_pchData, pData->data(),
  (pData- >nDataLength+1)*sizeof(TCHAR));
 }
}
其中Release 就是用来判断该内存的被引用情况的。
void CString::Release()
{
 if (GetData() != _afxDataNil)
 {
  if (InterlockedDecrement(&GetData()->nRefs) <= 0)
   FreeData(GetData());
 }
}

当多个对象共享同一块内存时,这块内存就属于多个对象,而不在属于原来的申请这块内存的那个对象了。但是,每个对象在其生命结束时,都首先将这块内存的引用减一,然后再判断这个引用值,如果小于等于零时,就将其释放,否则,将之交给另外的正在引用这块内存的对象控制。

CString使用这种数据结构,对于大数据量的字符串操作,可以节省很多频繁申请释放内存的时间,有助于提升系统性能。

通过上面的分析,我们已经对CString的内部机制已经有了一个大致的了解了。总的说来MFC中的CString是比较成功的。但是,由于数据结构比较复杂(使用CStringData),所以在使用的时候就出现了很多的问题,最典型的一个就是用来描述内存块属性的属性值和实际的值不一致。出现这个问题的原因就是CString为了方便某些应用,提供了一些operations,这些operation可以直接返回内存块中的字符串的地址值,用户可以通过对这个地址值指向的地址进行修改,但是,修改后又没有调用相应的operations1使CStringData中的值来保持一致。比如,用户可以首先通过operations得到字符串地址,然后将一些新的字符增加到这个字符串中,使得字符串的长度增加,但是,由于是直接通过指针修改的,所以描述该字符串长度的CStringData中的nDataLength却还是原来的长度,因此当通过GetLength获取字符串长度时,返回的必然是不正确的。

存在这些问题的operations下面一一介绍。

下面是几个例子,主要是错误的例子,来加深理解.
1

CString strTest = "123";
char* p = strTest.GetBuffer(0
);
int i =
 atoi(p);
strTest.ReleaseBuffer();

这种用法当然没有错,但是我认为这里的GetBuffer/ReleaseBuffer是没有必要的,为什么呢?因为
int __cdecl atoi(const char *) 的参数是const char*,CString的内部数据肯定不会被修改的.
所以上面的代码可以直接写成

CString strTest = "123";
int i = atoi((LPCTSTR)strTest);

顺便说一下GetBuffer的参数问题,网上的例子中,很多都是GetBuffer(5) GetBuffer(10)这样的常数,实际中的程序不可能是这么容易事先知道的,所以也就有了strTest.GetBuffer(strTest.GetLength() )的写法.其实,GetBuffer(0)就可以了.可以由GetBuffer的源码得到验证.

2

    CString strTest = "123 45";

    
//some other code

    CString strTest2 = strTest;
    
char seps[] = " "
;
    
char* pToken = 0
;
    
//char* pStr = strTest2.GetBuffer(0);

    pToken = strtok((char*)(LPCTSTR)strTest2, seps);
    //pToken = strtok(pStr, seps);
    
while
(pToken)
        pToken 
=
 strtok(NULL, seps);
         //strTest2.ReleaseBuffer(0);

运行上面的代码,可以看到strTest的值也变了,呵呵,这就是程序中一些关与CString的奇怪问题的起源.如果用注释中的GetBuffer/ReleaseBuffer方法,就一点问题也没有了.
同样,对于ReleaseBuffer的参数,缺省的是-1,但是我不建议.因为-1表示使用当前的00结束符位置来确定新的长度.而上面的例子中,strtok是会重新设置00结束符的,所以,安全的做法,就是把这个CString的长度设为0,ReleaseBuffer(0),反正它的内容已经变了,也没有人要用了.
说明一下,GetBuffer/ReleaseBuffer方法只能保证strTest不变,strTest2还是会变的.所以,对于一个成员变量,比如m_strTest2调用ReleaseBuffer要多一个心眼,局部变量就不用想这么多了.
那么怎么从最开始就意识到程序写错了呢?上面代码中(char*)(LPCTSTR)是很危险的,把const去掉了,否则strtok是编译不过的,也从一个侧面说明了const的重要性.

本文来自: 乘风原创程序(http://www.qqcf.com) 详细出处参考:http://study.qqcf.com/web/522/97976.htm

http://www.cppblog.com/flyingxu/archive/2006/03/21/4430.aspx

抱歉!评论已关闭.