随笔-341  评论-2670  文章-0  trackbacks-0
    复杂的东西写多了,如今写点简单的好了。由于功能上的需要,Vczh Library++3.0被我搞得很离谱。为了开发维护的遍历、减少粗心犯下的错误以及增强单元测试、回归测试和测试工具,因此记录下一些开发上的小技巧,以便抛砖引玉,造福他人。欢迎高手来喷,菜鸟膜拜。

    上一篇文章讲到了如何检查内存泄露。其实只要肯用C++的STL里面的高级功能的话,内存泄露是很容易避免的。我在开发Vczh Library++ 3.0的时候,所有的测试用例都保证跑完了没有内存泄露。但是很可惜有些C++团队不能使用异常,更甚者不允许写构造函数析构函数之类,前一个还好,后一个简直就是在用C。当然有这些变态规定的地方STL都是用不了的,所以我们更加需要扎实的基础来开发C++程序。

    今天这一篇主要还是讲指针的问题。因为上一篇文章一笔带过,今天就来详细讲内存泄漏或者野指针发生的各种情况。当然我不可能一下子举出全部的例子,只能说一些常见的。

    一、错误覆盖内存。

    之前提到的不能随便乱memset其实就是为了避免这个问题的。其实memcpy也不能乱用,我们来看一个例子,最简单的:
 1 #define MAX_STRING 20;
 2 
 3 struct Student
 4 {
 5   char name[MAX_STRING];
 6   char id[MAX_STRING];
 7   int chinese;
 8   int math;
 9   int english;
10 };

    大家对这种结构肯定十分熟悉,毕竟是大学时候经常要写的作业题……好了,大家很容易看得出来这其实是C语言的经典写法。我们拿到手之后,一般会先初始化一下,然后赋值。
1 Student vczh;
2 memset(&vczh, 0sizeof(vczh));
3 strcpy(vczh.name, "vczh");
4 strcpy(vczh.id, "VCZH'S ID");
5 vczh.chinese=70;
6 vczh.math=90;
7 vczh.english=80;

    为什么要在这里使用memset呢?memset的用处是将一段内存的每一个字节都设置成同一个数字。这里是0,因此两个字符串成员的所有字节都会变成0。因此在memset了Student之后,我们通过正常方法来访问name和id的时候都会得到空串。而且如果Student里面有指针的话,0指针代表的是没有指向任何有效对象,因此这个时候对指针指向的对象进行读写就会立刻崩溃。对于其他数值,0一般作为初始值也不会有什么问题(double什么的要小心)。这就是我们写程序的时候使用memset的原因。

    好了,如今社会进步,人民当家做主了,死程们再也不需要受到可恶的C语言剥削了,我们可以使用C++!因此我们借助STL的力量把Student改写成下面这种带有C++味道的形式:
1 struct Student
2 {
3   std::string name;
4   std::string id;
5   int chinese;
6   int math;
7   int english;
8 };

    我们仍然需要对Student进行初始化,不然三个分数还是随机值。但是我们又不想每一次创建的时候都对他们分别进行赋值初始化城0。这个时候你心里可能还是想着memset,这就错了!在memset的时候,你会把std::string内部的不知道什么东西也给memset掉。假如一个空的std::string里面存放的指针指向的是一个空的字符串而不是用0来代表空的时候,一下子内部的指针就被你刷成0,等下std::string的析构函数就没办法delete掉指针了,于是内存泄露就出现了。有些朋友可能不知道上面那句话说的是什么意思,我们现在来模拟一下不能memset的std::string要怎么实现。

    为了让memset一定出现内存泄露,那么std::string里面的指针必须永远都指向一个有效的东西。当然我们还需要在字符串进行复制的时候复制指针。我们这里不考虑各种优化技术,用最简单的方法做一个字符串出来:
 1 class String
 2 {
 3 private:
 4   char* buffer;
 5 
 6 public:
 7   String()
 8   {
 9     buffer=new char[1];
10     buffer[0]=0;
11   }
12 
13   String(const char* s)
14   {
15     buffer=new char[strlen(s)+1];
16     strcpy(buffer, s);
17   }
18 
19   String(const String& s)
20   {
21     buffer=new char[strlen(s.buffer)+1];
22     strcpy(buffer, s.buffer);
23   }
24 
25   ~String()
26   {
27     delete[] buffer;
28   }
29 
30   String& operator=(const String& s)
31   {
32     delete[] buffer;
33     buffer=new char[strlen(s.buffer)+1];
34     strcpy(buffer, s.buffer);
35   }
36 };

    于是我们来做一下memset。首先定义一个字符串变量,其次memset掉,让我们看看会发生什么事情:
1 string s;
2 memset(&s, 0sizeof(s));

    第一行我们构造了一个字符串s。这个时候字符串的构造函数就会开始运行,因此strcmp(s.buffer, "")==0。第二行我们把那个字符串给memset掉了。这个时候s.buffer==0。于是函数结束了,字符串的析构函数尝试delete这个指针。我们知道delete一个0是不会有问题的,因此程序不会发生错误。我们活生生把构造函数赋值给buffer的new char[1]给丢了!铁定发生内存泄露!

    好了,提出问题总要解决问题,我们不使用memset的话,怎么初始化Student呢?这个十分好做,我们只需要为Student加上构造函数即可:
1 struct Student
2 {
3   .//不重复那些声明
4 
5   Student():chinese(0),math(0),english(0)
6   {
7   }
8 };

    这样就容易多了。每当我们定义一个Student变量的时候,所有的成员都初始化好了。name和id因为string的构造函数也自己初始化了,因此所有的成员也都初始化了。加入Student用了一半我们想再初始化一下怎么办呢?也很容易:
1 Student vczh;
2 .//各种使用
3 vczh=Student();

    经过一个等号操作符的调用,旧Student的所有成员就被一个新的初始化过的Student给覆盖了,就如同我们对一个int变量重新赋值一样常见。当然因为各种复制经常会出现,因此我们也要跟上面贴出来的string的例子一样,实现好那4个函数。至此我十分不理解为什么某些团队不允许使用构造函数,我猜就是为了可以memset,其实是很没道理的。

    二、异常。

    咋一看内存泄露跟异常好像没什么关系,但实际上这种情况更容易发生。我们来看一个例子:
 1 char* strA=new char[MAX_PATH];
 2 if(GetXXX(strA, MAX_PATH)==ERROR) goto RELEASE_STRA;
 3 char* strB=new char[MAX_PATH];
 4 if(GetXXX(strB, MAX_PATH)==ERROR) goto RELEASE_STRB;
 5 
 6 DoSomething(strA, strB);
 7 
 8 RELEASE_STRB:
 9 delete[] strB;
10 RELEASE_STRA:
11 delete[] strA;

    相信这肯定是大家的常用模式。我在这里也不是教唆大家使用goto,不过对于这种例子来说,用goto是最优美的解决办法了。但是大家可以看出来,我们用的是C++,因为这里有new。如果DoSomething发生了异常怎么办呢?如果GetXXX发生了异常怎么办呢?我们这里没有任何的try-catch,一有异常,函数里克结束,两行可怜的delete就不会被执行到了,于是内存泄漏发生了

    那我们如何避免这种情况下的内存泄露呢?一些可爱的小盆友可能会想到,既然是因为没有catch异常才发生的内存泄露,那我们来catch吧:
 1 char* strA=new char[MAX_PATH];
 2 try
 3 {
 4   if(GetXXX(strA, MAX_PATH)==ERROR) goto RELEASE_STRA;
 5   char* strB=new char[MAX_PATH];
 6   try
 7   {
 8     if(GetXXX(strB, MAX_PATH)==ERROR) goto RELEASE_STRB;
 9     DoSomething(strA, strB);
10   }
11   catch()
12   {
13     delete[] strB;
14     throw;
15   }
16 }
17 catch()
18 {
19   delete[] strA;
20   throw;
21 }
22 
23 RELEASE_STRB:
24 delete[] strB;
25 RELEASE_STRA:
26 delete[] strA;

    你能接受吗?当然是不能的。问题出在哪里呢?因为C++没有try-finally。你看这些代码到处都是雷同的东西,显然我们需要编译器帮我们把这些问题搞定。最好的解决方法是什么呢?显然还是构造函数和析构函数。总之记住,如果想要事情成对发生,那么使用构造函数和析构函数

    第一步,GetXXX显然只能支持C模式的东西,因此我们要写一个支持C++的:
 1 bool GetXXX2(string& s)
 2 {
 3   char* str=new char[MAX_PATH];
 4   bool result;
 5   try
 6   {
 7     result=GetXXX(str, MAX_PATH);
 8     if(result)s=str;
 9   }
10   catch()
11   {
12     delete[] str;
13     throw;
14   }
15   delete[] str;
16   return result;
17 }

    借助这个函数我们可以看到,因为有了GetXXX这种C的东西,导致我们多了多少麻烦。不过这总是一劳永逸的,有了GetXXX2和修改之后的DoSomething2之后,我们就可以用更简单的方法来做了:
1 string a,b;
2 if(GetXXX2(a) && GetXXX2(b))
3 {
4   DoSomething2(a, b);
5 }

    多么简单易懂。这个代码在任何地方发生了异常,所有new的东西都会被delete。这就是析构函数的一个好处。一个变量的析构函数在这个变量超出了作用域的时候一定会被调用,无论代码是怎么走出去的。

    今天就说到这里了。说了这么多还是想让大家不要小看构造函数和析构函数。那种微不足道的因为一小部分不是瓶颈的性能问题而放弃构造函数和析构函数的做法,终究是要为了修bug而加班的。只要明白并用好了构造函数、析构函数和异常,那么C++的特性也可以跟C一样清楚明白便于理解,而且写出来的代码更好看的。大家期待第三篇哈。
posted on 2010-06-24 02:12 陈梓瀚(vczh) 阅读(10133) 评论(23)  编辑 收藏 引用 所属分类: C++实用技巧

评论:
# re: C++实用技巧(二) 2010-06-24 04:53 | OwnWaterloo
>>0指针代表的是没有指向任何有效对象

空指针的二进制表示并不一定是全0 。
浮点数也一样, 0.0f, 0.0, 0.0lf的二进制表示都不一定是全0。
所以, 即使是C语言, 欲使用memset去将指针初始化为空, 或者将浮点初始化为0, 都是不可移植的。


>>30 String& operator=(const String& s)
>>31 {
>>32 delete[] buffer;
>>33 buffer=new char[strlen(s.buffer)+1];
>>34 strcpy(buffer, s.buffer);
>>35 }

这个实现有问题, 当出现自赋值的时候:
String s;
s = s;
this->buffer和s.buffer是同一个指针。
32行delete之后, 已经是dangling pointer。
33行传递给strlen, 34行传递给strcpy都是错误的。


要么判断自赋值的情况:
if (this!=&s)
{
delete[] buffer;
size_t len = strlen(s.buffer)+1;
buffer = new char[ len ];
memcpy(buffer, s.buffer, len );
}

但是, 如果new新的buffer时出现异常, 就会导致this有一个dangling pointer。
为了安全, 可以先new, 再delete:
size_t len = strlen(s.buffer)+1;
char* p = new char[len]; // 之后操作都不会产生异常
memcpy(p, s.buffer, len );
delete[] buffer;
buffer = p;

先new再delete也可以不用判断自赋值的情况了。
  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 09:55 | zuhd
@OwnWaterloo
哥,你总是这么犀利,我给你留言看到没?  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 10:33 | OwnWaterloo
@zuhd
刚发现…… 那一天收到的通知太多, 被我直接全部标记为已读, 然后忘了……
通常用同名gmail邮箱, qq不怎么用……
  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 10:42 | 陈梓瀚(vczh)
@OwnWaterloo
为了迅速说明问题,那个string其实还有很多其他的缺陷的……因为在之后讲构造函数和析构函数的一篇上会着重处理这种case。

话说回来,我上一篇提到了,这个系列是默认你使用Visual C++的。  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 10:46 | 陈梓瀚(vczh)
@OwnWaterloo
话说回来,因为在C++里面,字面量“0”的确是代表空指针,因此如果空指针的二进制真的不是0的话,我可以认为是编译器的bug。因为从语法上讲,既然void* p=0;是对的,那么void* p=(void*)(int)0;也必须是对的。

除非从一开始就有C++0x的nullptr。  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 11:25 | OwnWaterloo
@陈梓瀚(vczh)
无论是C还是C++, 说的都是"编译时可求出0值的常数是空指针常量"。
可以将空指针赋值给任意指针类型的变量。
但并不保证赋值之后指针变量的二进制表示也是全0。


从语法上讲:
void* p = 0;
void* p=(void*)(int)0;
都是对的, 就像:

float f = 0;
float f = (float)(int)0;
而通常f的二进制表示并不是全0。
这是因为编译器是能看到这里的转型动作, 并加入适当的操作。


但这样写就是没有保证的:
void* p;
memset(&p, 0, sizeof p);
memset函数中已经不知道p的类型, 只能将p当作byte 数组。
就没有机会为指针类型作适当的调整。


再举个例子:
class A {};
class B {};
class C : public A, public B {};

C c;
C* pc = &c;
A* pa = pc;
B* pb = pc;
assert( pc==pa && pc==pb);

因为编译器知道pc,pa,pb的类型。
所以无论是赋值还是比较, 都会插入适当的操作。

而如果使用memset或者memcmp, 就挂了。


最后, 标准中有句话, 在calloc的脚注里。
The calloc function allocates space for an array
of nmemb objects, each of whose size is size.
The space is initialized to all bits zero.

Note that this need not be the same as the representation
of floating-point zero or a null pointer constant.
  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 12:40 | 付翔
指出个 小错误
#define MAX_STRING 20; 不要分号   回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 13:49 | 陈梓瀚(vczh)
@OwnWaterloo
话说回来,那什么地方的空指针的二进制不是0?  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 13:50 | 陈梓瀚(vczh)
@付翔
嗯嗯,这是个错误。  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 13:52 | 陈梓瀚(vczh)
@OwnWaterloo
不过这个二进制位不是0的事实更加反映出了构造函数的重要性,memset更加不能随便来了(当然指的是非Visual C++的情况)  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 14:12 | OwnWaterloo
@陈梓瀚(vczh)
http://linuxdude.com/Steve_Sumit/C-faq/q5.17.html
整个第5章都是讲null pointer的。
  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 14:35 | 陈梓瀚(vczh)
@OwnWaterloo
都是些从现在开始见都没见过的机器……  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 17:18 | 匿名
delete null
确定程序不会抛异常?  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 17:46 | 匿名(还是俺)
char* strA=new char[MAX_PATH];
这个也不能保证它一定不会抛异常。
new这个数组的时候,加入new到第M个(M小于MAX_PATH)时候抛出异常,这时候也会有内存泄露咯  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-24 19:28 | 陈梓瀚(vczh)
@匿名(还是俺)
如果new一个东西还是败了,那程序就直接log了崩溃了吧  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-25 02:25 | paul_simon
1 string s;
2 memset(&s, 0, sizeof(s));
//这里有个问题想和博主探讨,下文中提到的buffer是String的一个private成
//员,memset函数是不是该在String的成员函数里面调用,比如:
String::mems()
{
memset(buffer,0,sizeof(buffer));
}
//或者可否考虑在String::String()里加入memset函数
//QQ:29975723,谢谢  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-25 13:00 | 陈梓瀚(vczh)
@paul_simon
String当然要为自己负责了,如果String自己觉得memset合适,那当然可以。不过你那么搞有问题,除非buffer是一个数组而不是指针。

但是外部不能memset掉string。  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-25 17:07 | paul_simon
@陈梓瀚(vczh)
String的构造函数决定了buffer是个数组,这里的String和student是两个不同的对象,String里的buffer成员不能被外部的memset所清零吧?
所以我说的是
1 string s;
2 memset(&s, 0, sizeof(s));//这里的实质不是是对buffer进行清零吗?  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-25 21:43 | 陈梓瀚(vczh)
@paul_simon
不是,因为类的私有部分是不透明的,所以你在外部操作的时候,不能以知道string的实现作为前提,人家改了怎么办,你就要加班了。  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-28 18:11 | 球球
如果memset用在子类的初始化时...  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-28 19:22 | 陈梓瀚(vczh)
@球球
子类也不能假设父类是如何实现的。  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-29 16:06 | chenq
用那么多代码去try catch那个GetXXX,为什么不直接用smart pointer呢  回复  更多评论
  
# re: C++实用技巧(二) 2010-06-30 01:58 | 陈梓瀚(vczh)
@chenq
因为用了smart pointer就不能告诉人们C风格的丑陋  回复  更多评论
  

只有注册用户登录后才能发表评论。
【推荐】超50万行VC++源码: 大型组态工控、电力仿真CAD与GIS源码库
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理