从CByteArray说起——浅论程序效率

有人说我对MFC一直很有偏见,其实不然,我只是觉得这是个适用性问题,因为很多时候我们根本不需要MFC,用了MFC,程序反而不好做。而MFC最为我诟病的是什么呢?除了多了个DLL的依赖之外就是效率的问题了。

 

前阵子接手过一些公司以前的程序,我总觉得运行太慢,我认为自己用VC++写的程序可以比它们快好几倍,那它们为什么这么慢呢?这个跟不恰当使用MFC肯定有关系。仔细看了代码,发现类似下面的代码贯穿了整个程序:

 

CByteArray cbDataBuffer;

CByteArray cbaResponse;

BYTE byPacketLen;

//省略N多代码

//…

for(int i=4; i

{

    cbaResponse.Add(cbDataBuffer.GetAt(i));

}

 

上面的代码再简单不过,就是把cbDataBuffer的特定内容复制到cbaResponse,对于CByteArray,大家都很熟悉了吧,MFCBYTE集合类,假设我已经预先知道两者的长度不可能超过256,我把代码改一下:

 

BYTE arrDataBuffer[256];

BYTE arrResponse[256];

BYTE byPacketLen;

//…

memcpy(arrResponse, &arrDataBuffer[4], byPacketLen-4);

 

你认为两者的效率差别有多大?

 

也许你会说上面两段代码作用是不同的,OK,现在请别钻牛角尖先,能用上面的代码解决的问题,我全部可以用下面的代码来解决,你总不能要求我把全部代码帖出来吧,这样的话文章就没有可读性了。

 

你认为memcpy是怎么样执行的?是不是类似下面这种实现?

 

void *memcpy( void *dest, const void *src, int count )

{

    for(int i=0; i

    {

        *((char *)dest+i)=*((char *)src+i);

    }

    return dest;

}

 

答案:应该不算是,memcpy是直接用汇编实现的,里面那个for被一条汇编指令取代,直接能完成这个内存复制操作,CPU的速度如此的快,执行几条简简单单的指令根本让人感觉不到耗时,可认为memcpy的效率是极高的。(至于具体的汇编代码,有兴趣的可以看看memcpy.asm这个文件)

 

而第一段代码的情况就不同了,用户定义了一个CByteArray,没有任何初始化,没指定它有多长,需要分配多少空间,或者是空间不足时候增量是多大,都没有指定,程序就直接调用了这个对象的Add方法,想想看,要是换成让你来实现CByteArray,应该怎么做。OK,知道你不爱动脑筋,我来分析一下吧:

 

第一次Add,发现根本就没有空间,长度是0,这时候得分配空间,那应该分配多少呢?应该分配需要Add的内容的长度,这时候Add的内容是一个BYTE,那就分配一个BYTE的空间,于是:

 

m_pData = new BYTE[1];

m_iLen = 1;

 

//然后把这个Add的内容Set到这个空间里去:

*m_pData = byAdd;

 

但很快你就发现这样不行,想想看,再Add怎么办?还得再new一个BYTE出来,接在原来这个后面,怎么接?使用链表结构?天啊,太复杂了,如果再AddAdd个几百几千个,那这个链表可真够复杂的,开销不可谓不大,链表还有个致命缺点,就是从中获取某个下标的值特别难,需要从头开始遍历,遇到GetAt()这样的函数就麻烦了,所以你很快就打消了这个想法,回到一开始。

 

好吧,发现这个时候长度是0new是肯定的了,但要new多少,你得建立一个增量,这个增量叫m_iGrowBy,每次增加的长度不是1,而是m_iGrowBy,然后你想办法给m_iGrowBy定一个初始值,定多少呢?定长了空间浪费,定小的一会儿要是超过了这个空间重新分配又麻烦,所以还真得动动脑筋,你这样考虑,如果数据很长,那说明之后增加内容的概率很大,所以要分配多一些,反之亦然。所以要new的大小,跟现在的长度有关系,你把它设计为1/8。那现在长度是0,是否应该就new0BYTE?当然不行,最少4BYTE,你心满意足地写下了下面的代码(大家先别太计较代码上写死的内容,着重理解设计思想):

 

//计算m_iGrowBy

m_iGrowBy = m_iLen / 8;

m_iGrowBy = m_iGrowBy < 4) ? 4 : m_iGrowBy;

//...

 

//第一次Addm_pData==NULL

m_pData = new BYTE[m_iGrowBy];

*m_pData = byAdd;

m_iLen = m_iGrowBy;

m_iDataLen = 1;

 

好了,如果再Add,也不怕:

 

if(m_iDataLen

    m_pData[m_iDataLen++] = byAdd;

 

但如果if不成立,那怎么办呢?刚才分配的那个空间不够啊,得增加,你得new一个更大的空间,把原先的数据复制到这个空间去,然后把原先分配的那个空间delete掉,代码如下:

 

//...

else

{

    //假设m_iGrowBy已经计算好

    BYTE *pNew = new BYTE[m_iLen+m_iGrowBy];

    m_iLen += m_iGrowBy;

    memcpy(pNew, m_pData, m_iDataLen);

    delete[] m_pData;

    m_pData = pNew;

    m_pData[m_iDataLen++] = byAdd;

}

 

这么一来,m_pData的值就可能发生了变化。是不是觉得很傻?但你还有什么更好的办法么?更重要的是:MFC是不是这么干的?最好的办法是直接看看MFC的代码,看了之后你会发现,MFC就是这么干的!当然为了更多的通用性,MFC的代码远比这个复杂,复杂就意味着什么?运行得更慢:-)。(具体代码参考AFXCOLL.INLAFXTEMPL.H

 

现在假设我们在Add之前调用了CByteArraySetSize,那又是什么情况呢?很明显,可能后面的那几次newdeletememcpy的开销就可以省掉了,对效率的提高是明显的,所以MFC如果用得好的话其实也不会太慢,前面我说了,效率低跟“不恰当使用MFC肯定有关系”。代码如下(再次声明,这些不完整的代码只是为了帮助理解设计思想):

 

void CByteArray::SetSize(INT iSize)

{

    ASSERT(iSize>=0)

    if(m_pData==NULL)

    {

        m_pData = new BYTE[iSize];

        m_iLen = iSize;

    }

    else

        //...

}

 

也许你要反驳我,光多出这么几次newdeletememcpy怎么会影响程序的效率呢?是的,偶尔这样是不会,但如果这种用法贯穿了整个程序,并且程序的大量的循环中都这么用,那对效率的影响就显而易见了。类似的还有CString,它也并没有你想像中的“聪明”,但比同CByteArrayCString更复杂一些,起码它还重载了“operator =”。

 

我后来改进了程序的代码,剔除了MFC,重新编写了程序,以前要执行不少于4分钟的程序现在只需要20来秒,让我们那些managers感到impressive

 

好了,关于MFC,暂时先提那么多,下面讲一个内存分配的问题。

 

我们都知道,C++使用关键字“new”来分配内存,“delete”来释放内存,而newdelete究竟又执行了些什么操作呢?其实在Windows环境下,它们最终都要调用到WindowsAPI。分配内存调的API是:

 

LPVOID HeapAlloc(HANDLE hHeap, DWORD dwFlags, SIZE_T dwBytes);

 

第一个参数是堆的句柄,每个进程都有个默认的堆,当然还可以用HeapCreate来创建新的堆,第二个参数是一些选项,第三个参数是要分配的大小。它的逆操作是:

 

BOOL HeapFree(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem);

 

第一第二个参数和HeapAlloc一样,第三个参数是HeapAlloc的返回值。如果要最快速度地利用内存的话,就直接调用这两个API,而不是用什么newdelete,看看new的代码,其实中间的执行过程还是蛮多的。但用newdelete的也有好处,那就是比较简便,“可移植”(我从来不考虑,我目前只为Windows写代码),另外就是可以很方便的使用_CrtDumpMemoryLeaks这种函数来检测内存泄露等,如果直接用Windows API的话,这些CRT函数就不管用了,还有一个问题,就是用Windows API的话得自己维护那个Heap句柄,这又好又不好,关于这个我可以另写一篇文章来讨论,而且由此可以引申出很多的问题,但这里不打算岔开太多,只给大家讲个小故事,关于C运行库的故事:

 

以前公司叫我写一个文件分类系统,文件的种类千千万万,所以我的程序必须考虑扩展性,当时考虑一种文件就用一个dll来判定,用C运行库把文件打开,fopen,获得FILE*,把这个FILE*传递给dll导出的函数,根据返回值判定这个文件,你们说这样会有问题么?答案是肯定的,程序出错了,但这是他们以前碰到的错误,我后来写的新程序就没碰到这种错误,为什么?我的做法是用Windows APICreateFile,获得文件的HANDLE,把这个HANDLE传给dll导出的函数,根据返回值判定这个文件。用C运行库出问题了,用Windows API却没有问题,为什么?因为每个模块(exe算一个模块,dll算一个模块)都拥有自己的HeapC运行库会在每个Heap中创建一个运行上下文,把exe打开的FILE*传递给dll,由于不同的Heap有不同的上下文,理解不同,当然会出错,而用CreateFile获取的HANDLE是个内核对象句柄,在进程中的理解都是相同的,所以没问题。

 

从上面这个故事可以看到C运行库和Windows API的一些不同之处,而效率上看来,Windows API是肯定胜出的,要提高代码的效率,就要从细节入手,用正确的代码,一般来说,通用性越好,易用性越好,代码效率越差,开发者应该从中选择一个balance point。我打算以后再写一些关于程序效率的文章,欢迎到时再光顾我的这个blog

你可能感兴趣的:(Windows编程)