有人说我对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,大家都很熟悉了吧,MFC的BYTE集合类,假设我已经预先知道两者的长度不可能超过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出来,接在原来这个后面,怎么接?使用链表结构?天啊,太复杂了,如果再Add,Add个几百几千个,那这个链表可真够复杂的,开销不可谓不大,链表还有个致命缺点,就是从中获取某个下标的值特别难,需要从头开始遍历,遇到GetAt()这样的函数就麻烦了,所以你很快就打消了这个想法,回到一开始。
好吧,发现这个时候长度是0,new是肯定的了,但要new多少,你得建立一个增量,这个增量叫m_iGrowBy,每次增加的长度不是1,而是m_iGrowBy,然后你想办法给m_iGrowBy定一个初始值,定多少呢?定长了空间浪费,定小的一会儿要是超过了这个空间重新分配又麻烦,所以还真得动动脑筋,你这样考虑,如果数据很长,那说明之后增加内容的概率很大,所以要分配多一些,反之亦然。所以要new的大小,跟现在的长度有关系,你把它设计为1/8。那现在长度是0,是否应该就new个0个BYTE?当然不行,最少4个BYTE,你心满意足地写下了下面的代码(大家先别太计较代码上写死的内容,着重理解设计思想):
//计算m_iGrowBy m_iGrowBy = m_iLen / 8; m_iGrowBy = m_iGrowBy < 4) ? 4 : m_iGrowBy; //...
//第一次Add,m_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.INL和AFXTEMPL.H)
现在假设我们在Add之前调用了CByteArray的SetSize,那又是什么情况呢?很明显,可能后面的那几次new、delete和memcpy的开销就可以省掉了,对效率的提高是明显的,所以MFC如果用得好的话其实也不会太慢,前面我说了,效率低跟“不恰当使用MFC肯定有关系”。代码如下(再次声明,这些不完整的代码只是为了帮助理解设计思想):
void CByteArray::SetSize(INT iSize) { ASSERT(iSize>=0) if(m_pData==NULL) { m_pData = new BYTE[iSize]; m_iLen = iSize; } else //... } |
也许你要反驳我,光多出这么几次new,delete和memcpy怎么会影响程序的效率呢?是的,偶尔这样是不会,但如果这种用法贯穿了整个程序,并且程序的大量的循环中都这么用,那对效率的影响就显而易见了。类似的还有CString,它也并没有你想像中的“聪明”,但比同CByteArray,CString更复杂一些,起码它还重载了“operator =”。
我后来改进了程序的代码,剔除了MFC,重新编写了程序,以前要执行不少于4分钟的程序现在只需要20来秒,让我们那些managers感到impressive。
好了,关于MFC,暂时先提那么多,下面讲一个内存分配的问题。
我们都知道,C++使用关键字“new”来分配内存,“delete”来释放内存,而new和delete究竟又执行了些什么操作呢?其实在Windows环境下,它们最终都要调用到Windows的API。分配内存调的API是:
LPVOID HeapAlloc(HANDLE hHeap, DWORD dwFlags, SIZE_T dwBytes);
第一个参数是堆的句柄,每个进程都有个默认的堆,当然还可以用HeapCreate来创建新的堆,第二个参数是一些选项,第三个参数是要分配的大小。它的逆操作是:
BOOL HeapFree(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem);
第一第二个参数和HeapAlloc一样,第三个参数是HeapAlloc的返回值。如果要最快速度地利用内存的话,就直接调用这两个API,而不是用什么new,delete,看看new的代码,其实中间的执行过程还是蛮多的。但用new和delete的也有好处,那就是比较简便,“可移植”(我从来不考虑,我目前只为Windows写代码),另外就是可以很方便的使用_CrtDumpMemoryLeaks这种函数来检测内存泄露等,如果直接用Windows API的话,这些CRT函数就不管用了,还有一个问题,就是用Windows API的话得自己维护那个Heap句柄,这又好又不好,关于这个我可以另写一篇文章来讨论,而且由此可以引申出很多的问题,但这里不打算岔开太多,只给大家讲个小故事,关于C运行库的故事:
以前公司叫我写一个文件分类系统,文件的种类千千万万,所以我的程序必须考虑扩展性,当时考虑一种文件就用一个dll来判定,用C运行库把文件打开,fopen,获得FILE*,把这个FILE*传递给dll导出的函数,根据返回值判定这个文件,你们说这样会有问题么?答案是肯定的,程序出错了,但这是他们以前碰到的错误,我后来写的新程序就没碰到这种错误,为什么?我的做法是用Windows API,CreateFile,获得文件的HANDLE,把这个HANDLE传给dll导出的函数,根据返回值判定这个文件。用C运行库出问题了,用Windows API却没有问题,为什么?因为每个模块(exe算一个模块,dll算一个模块)都拥有自己的Heap,C运行库会在每个Heap中创建一个运行上下文,把exe打开的FILE*传递给dll,由于不同的Heap有不同的上下文,理解不同,当然会出错,而用CreateFile获取的HANDLE是个内核对象句柄,在进程中的理解都是相同的,所以没问题。
从上面这个故事可以看到C运行库和Windows API的一些不同之处,而效率上看来,Windows API是肯定胜出的,要提高代码的效率,就要从细节入手,用正确的代码,一般来说,通用性越好,易用性越好,代码效率越差,开发者应该从中选择一个balance point。我打算以后再写一些关于程序效率的文章,欢迎到时再光顾我的这个blog。