深入C++的new.docx
- 文档编号:30251637
- 上传时间:2023-08-13
- 格式:DOCX
- 页数:14
- 大小:63.71KB
深入C++的new.docx
《深入C++的new.docx》由会员分享,可在线阅读,更多相关《深入C++的new.docx(14页珍藏版)》请在冰豆网上搜索。
深入C++的new
深入C++的new
“new”是C++的一个关键字,同时也是操作符。
关于new的话题非常多,因为它确实比较复杂,也非常神秘,下面我将把我了解到的与new有关的内容做一个总结。
new的过程
当我们使用关键字new在堆上动态创建一个对象时,它实际上做了三件事:
获得一块内存空间、调用构造函数、返回正确的指针。
当然,如果我们创建的是简单类型的变量,那么第二步会被省略。
假如我们定义了如下一个类A:
classA
{
inti;
public:
A(int_i):
i(_i*_i){}
voidSay() {printf("i=%d/n",i);}
};
//调用new:
A*pa=newA(3);
那么上述动态创建一个对象的过程大致相当于以下三句话(只是大致上):
A*pa=(A*)malloc(sizeof(A));
pa->A:
:
A(3);
returnpa;
虽然从效果上看,这三句话也得到了一个有效的指向堆上的A对象的指针pa,但区别在于,当malloc失败时,它不会调用分配内存失败处理程序new_handler,而使用new的话会的。
因此我们还是要尽可能的使用new,除非有一些特殊的需求。
new的三种形态
到目前为止,本文所提到的new都是指的“newoperator”或称为“newexpression”,但事实上在C++中一提到new,至少可能代表以下三种含义:
newoperator、operatornew、placementnew。
newoperator就是我们平时所使用的new,其行为就是前面所说的三个步骤,我们不能更改它。
但具体到某一步骤中的行为,如果它不满足我们的具体要求时,我们是有可能更改它的。
三个步骤中最后一步只是简单的做一个指针的类型转换,没什么可说的,并且在编译出的代码中也并不需要这种转换,只是人为的认识罢了。
但前两步就有些内容了。
newoperator的第一步分配内存实际上是通过调用operatornew来完成的,这里的new实际上是像加减乘除一样的操作符,因此也是可以重载的。
operatornew默认情况下首先调用分配内存的代码,尝试得到一段堆上的空间,如果成功就返回,如果失败,则转而去调用一个new_hander,然后继续重复前面过程。
如果我们对这个过程不满意,就可以重载operatornew,来设置我们希望的行为。
例如:
classA
{
public:
void*operatornew(size_tsize)
{
printf("operatornewcalled/n");
return:
:
operatornew(size);
}
};
A*a=newA();
这里通过:
:
operatornew调用了原有的全局的new,实现了在分配内存之前输出一句话。
全局的operatornew也是可以重载的,但这样一来就不能再递归的使用new来分配内存,而只能使用malloc了:
void*operatornew(size_tsize)
{
printf("globalnew/n");
returnmalloc(size);
}
相应的,delete也有deleteoperator和operatordelete之分,后者也是可以重载的。
并且,如果重载了operatornew,就应该也相应的重载operatordelete,这是良好的编程习惯。
new的第三种形态——placementnew是用来实现定位构造的,因此可以实现newoperator三步操作中的第二步,也就是在取得了一块可以容纳指定类型对象的内存后,在这块内存上构造一个对象,这有点类似于前面代码中的“p->A:
:
A(3);”这句话,但这并不是一个标准的写法,正确的写法是使用placementnew:
#include
voidmain()
{
chars[sizeof(A)];
A*p=(A*)s;
new(p)A(3);//p->A:
:
A(3);
p->Say();
}
对头文件
这里“new(p)A(3)”这种奇怪的写法便是placementnew了,它实现了在指定内存地址上用指定类型的构造函数来构造一个对象的功能,后面A(3)就是对构造函数的显式调用。
这里不难发现,这块指定的地址既可以是栈,又可以是堆,placement对此不加区分。
但是,除非特别必要,不要直接使用placementnew,这毕竟不是用来构造对象的正式写法,只不过是newoperator的一个步骤而已。
使用newoperator地编译器会自动生成对placementnew的调用的代码,因此也会相应的生成使用delete时调用析构函数的代码。
如果是像上面那样在栈上使用了placementnew,则必须手工调用析构函数,这也是显式调用析构函数的唯一情况:
p->~A();
当我们觉得默认的newoperator对内存的管理不能满足我们的需要,而希望自己手工的管理内存时,placementnew就有用了。
STL中的allocator就使用了这种方式,借助placementnew来实现更灵活有效的内存管理。
处理内存分配异常
正如前面所说,operatornew的默认行为是请求分配内存,如果成功则返回此内存地址,如果失败则调用一个new_handler,然后再重复此过程。
于是,想要从operatornew的执行过程中返回,则必然需要满足下列条件之一:
l 分配内存成功
l new_handler中抛出bad_alloc异常
l new_handler中调用exit()或类似的函数,使程序结束
于是,我们可以假设默认情况下operatornew的行为是这样的:
void*operatornew(size_tsize)
{
void*p=null
while(!
(p=malloc(size)))
{
if(null==new_handler)
throwbad_alloc();
try
{
new_handler();
}
catch(bad_alloce)
{
throwe;
}
catch(…)
{}
}
returnp;
}
在默认情况下,new_handler的行为是抛出一个bad_alloc异常,因此上述循环只会执行一次。
但如果我们不希望使用默认行为,可以自定义一个new_handler,并使用std:
:
set_new_handler函数使其生效。
在自定义的new_handler中,我们可以抛出异常,可以结束程序,也可以运行一些代码使得有可能有内存被空闲出来,从而下一次分配时也许会成功,也可以通过set_new_handler来安装另一个可能更有效的new_handler。
例如:
voidMyNewHandler()
{
printf(“Newhandlercalled!
/n”);
throwstd:
:
bad_alloc();
}
std:
:
set_new_handler(MyNewHandler);
这里new_handler程序在抛出异常之前会输出一句话。
应该注意,在new_handler的代码里应该注意避免再嵌套有对new的调用,因为如果这里调用new再失败的话,可能会再导致对new_handler的调用,从而导致无限递归调用。
——这是我猜的,并没有尝试过。
在编程时我们应该注意到对new的调用是有可能有异常被抛出的,因此在new的代码周围应该注意保持其事务性,即不能因为调用new失败抛出异常来导致不正确的程序逻辑或数据结构的出现。
例如:
classSomeClass
{
staticintcount;
SomeClass(){}
public:
staticSomeClass*GetNewInstance()
{
count++;
returnnewSomeClass();
}
};
静态变量count用于记录此类型生成的实例的个数,在上述代码中,如果因new分配内存失败而抛出异常,那么其实例个数并没有增加,但count变量的值却已经多了一个,从而数据结构被破坏。
正确的写法是:
staticSomeClass*GetNewInstance()
{
SomeClass*p=newSomeClass();
count++;
returnp;
}
这样一来,如果new失败则直接抛出异常,count的值不会增加。
类似的,在处理线程同步时,也要注意类似的问题:
voidSomeFunc()
{
lock(someMutex);//加一个锁
deletep;
p=newSomeClass();
unlock(someMutex);
}
此时,如果new失败,unlock将不会被执行,于是不仅造成了一个指向不正确地址的指针p的存在,还将导致someMutex永远不会被解锁。
这种情况是要注意避免的。
(参考:
C++箴言:
争取异常安全的代码)
STL的内存分配与traits技巧
在《STL原码剖析》一书中详细分析了SGISTL的内存分配器的行为。
与直接使用newoperator不同的是,SGISTL并不依赖C++默认的内存分配方式,而是使用一套自行实现的方案。
首先SGISTL将可用内存整块的分配,使之成为当前进程可用的内存,当程序中确实需要分配内存时,先从这些已请求好的大内存块中尝试取得内存,如果失败的话再尝试整块的分配大内存。
这种做法有效的避免了大量内存碎片的出现,提高了内存管理效率。
为了实现这种方式,STL使用了placementnew,通过在自己管理的内存空间上使用placementnew来构造对象,以达到原有newoperator所具有的功能。
template
inlinevoidconstruct(T1*p,constT2&value)
{
new(p)T1(value);
}
此函数接收一个已构造的对象,通过拷贝构造的方式在给定的内存地址p上构造一个新对象,代码中后半截T1(value)便是placementnew语法中调用构造函数的写法,如果传入的对象value正是所要求的类型T1,那么这里就相当于调用拷贝构造函数。
类似的,因使用了placementnew,编译器不会自动产生调用析构函数的代码,需要手工的实现:
template
inlinevoiddestory(T*pointer)
{
pointer->~T();
}
与此同时,STL中还有一个接收两个迭代器的destory版本,可将某容器上指定范围内的对象全部销毁。
典型的实现方式就是通过一个循环来对此范围内的对象逐一调用析构函数。
如果所传入的对象是非简单类型,这样做是必要的,但如果传入的是简单类型,或者根本没有必要调用析构函数的自定义类型(例如只包含数个int成员的结构体),那么再逐一调用析构函数是没有必要的,也浪费了时间。
为此,STL使用了一种称为“typetraits”的技巧,在编译器就判断出所传入的类型是否需要调用析构函数:
template
inlinevoiddestory(ForwardIteratorfirst,ForwardIteratorlast)
{
__destory(first,last,value_type(first));
}
其中value_type()用于取出迭代器所指向的对象的类型信息,于是:
template
inlinevoid__destory(ForwardIteratorfirst,ForwardIteratorlast,T*)
{
typedeftypename__type_traits
:
has_trivial_destructortrivial_destructor;
__destory_aux(first,last,trivial_destructor());
}
//如果需要调用析构函数:
template
inlinevoid__destory_aux(ForwardIteratorfirst,ForwardIteratorlast,__false_type)
{
for(;first destory(&*first);//因first是迭代器,*first取出其真正内容,然后再用&取地址 } //如果不需要,就什么也不做: tempalte inlinevoid__destory_aux(ForwardIteratorfirst,ForwardIteratorlast,__true_type) {} 因上述函数全都是inline的,所以多层的函数调用并不会对性能造成影响,最终编译的结果根据具体的类型就只是一个for循环或者什么都没有。 这里的关键在于__type_traits 其中__true_type、__false_type只不过是两个没有任何内容的类,对程序的执行结果没有什么意义,但在编译器看来它对模板如何特化就具有非常重要的指导意义了,正如上面代码所示的那样。 __type_traits struct__true_type{}; struct__false_type{}; template struct__type_traits { public: typedef__false_typehas_trivial_destructor; …… }; template<>//模板特化 struct__type_traits { public: typedef__true_typehas_trivial_destructor; …… }; ……//其他简单类型的特化版本 如果要把一个自定义的类型MyClass也定义为不调用析构函数,只需要相应的定义__type_traits template<> struct__type_traits { public: typedef__true_typehas_trivial_destructor; …… }; 模板是比较高级的C++编程技巧,模板特化、模板偏特化就更是技巧性很强的东西,STL中的type_traits充分借助模板特化的功能,实现了在程序编译期通过编译器来决定为每一处调用使用哪个特化版本,于是在不增加编程复杂性的前提下大大提高了程序的运行效率。 更详细的内容可参考《STL源码剖析》第二、三章中的相关内容。 带有“[]”的new和delete 我们经常会通过new来动态创建一个数组,例如: char*s=newchar[100]; …… deletes; 严格的说,上述代码是不正确的,因为我们在分配内存时使用的是new[],而并不是简单的new,但释放内存时却用的是delete。 正确的写法是使用delete[]: delete[]s; 但是,上述错误的代码似乎也能编译执行,并不会带来什么错误。 事实上,new与new[]、delete与delete[]是有区别的,特别是当用来操作复杂类型时。 假如针对一个我们自定义的类MyClass使用new[]: MyClass*p=newMyClass[10]; 上述代码的结果是在堆上分配了10个连续的MyClass实例,并且已经对它们依次调用了构造函数,于是我们得到了10个可用的对象,这一点与Java、C#有区别的,Java、C#中这样的结果只是得到了10个null。 换句话说,使用这种写法时MyClass必须拥有不带参数的构造函数,否则会发现编译期错误,因为编译器无法调用有参数的构造函数。 当这样构造成功后,我们可以再将其释放,释放时使用delete[]: delete[]p; 当我们对动态分配的数组调用delete[]时,其行为根据所申请的变量类型会有所不同。 如果p指向简单类型,如int、char等,其结果只不过是这块内存被回收,此时使用delete[]与delete没有区别,但如果p指向的是复杂类型,delete[]会针对动态分配得到的每个对象调用析构函数,然后再释放内存。 因此,如果我们对上述分配得到的p指针直接使用delete来回收,虽然编译期不报什么错误(因为编译器根本看不出来这个指针p是如何分配的),但在运行时(DEBUG情况下)会给出一个Debugassertionfailed提示。 到这里,我们很容易提出一个问题——delete[]是如何知道要为多少个对象调用析构函数的? 要回答这个问题,我们可以首先看一看new[]的重载。 classMyClass { inta; public: MyClass(){printf("ctor/n");} ~MyClass(){printf("dtor/n");} }; void*operatornew[](size_tsize) { void*p=operatornew(size); printf("callingnew[]withsize=%daddress=%p/n",size,p); returnp; } //主函数 MyClass*mc=newMyClass[3]; printf("addressofmc=%p/n",mc); delete[]mc; 运行此段代码,得到的结果为: (VC2005) callingnew[]withsize=16address=003A5A58 ctor ctor ctor addressofmc=003A5A5C dtor dtor dtor 虽然对构造函数和析构函数的调用结果都在预料之中,但所申请的内存空间大小以及地址的数值却出现了问题。 我们的类MyClass的大小显然是4个字节,并且申请的数组中有3个元素,那么应该一共申请12个字节才对,但事实上系统却为我们申请了16字节,并且在operatornew[]返后我们得到的内存地址是实际申请得到的内存地址值加4的结果。 也就是说,当为复杂类型动态分配数组时,系统自动在最终得到的内存地址前空出了4个字节,我们有理由相信这4个字节的内容与动态分配数组的长度有关。 通过单步跟踪,很容易发现这4个字节对应的int值为0x00000003,也就是说记录的是我们分配的对象的个数。 改变一下分配的个数然后再次观察的结果证实了我的想法。 于是,我们也有理由认为new[]operator的行为相当于下面的伪代码: template T*New[](intcount) { intsize=sizeof(T)*count+4; void*p=T: : operatornew[](size); *(int*)p=count; T*pt=(T*)((int)p+4); for(inti=0;i new(&pt[i])T(); returnpt; } 上述示意性的代码省略了异常处理的部分,只是展示当我们对一个复杂类型使用new[]来动态分配数组时其真正的行为是什么,从中可以看到它分配了比预期多4个字节的内存并用它来保存对象的个数,然后对于后面每一块空间使用placementnew来调用无参构造函数,这也就解释了为什么这种情况下类必须有无参构造函数,最后再将首地址返回。 类似的,我们很容易写出相应的delete[]的实现代码: template voidDelete[](T*pt) { intcount=((int*)pt)[-1]; for(inti=0;i pt[i].~T(); void*p=(void*)((int)pt–4); T: : operatordelete[](p); } 由此可见,在默认情况下operatornew[]与operatornew的行为是相同的,operatordelete[]与operatordelete也是,不同的是newoperator与new[]operator、deleteoperator与delete[]operator。 当然,我们可以根据不同的需要来选择重载带有和不带有“[]”的operatornew和delete,以满足不同的具体需求。 把前面类MyClass的代码稍做修改——注释掉析构函数,然后再来看看程序的输出: callingnew[]withsize=12address=003A5A58 ctor ctor ctor addressofmc=003A5A58 这一次,new[]老老实实的申请了12个字节的内存,并且申请的结果与new[]operator返回的结果也是相同的,看来,是否在前面添加4个字节,只取决于这个类有没有析构函数,当然,这么说并不确切,正确的说法是这个类是否需要调用构造函数,因为如下两种情况下虽然这个类没声明析构函数,但还是多申请了4个字节: 一是这个类中拥有需要调用析构函数的成员,二是这个类继承自需要调用析构函数的类。 于是,我们可以递归的定义“需要调用析构函数的类”为以下三种情况之一: 1显式的声明了析构函数的 2拥有需要调用析构函数的类的成员的 3继承自需要调用析构函数的类的 类似的,动态申请简单类型的数组时,也不会多申请4个字节。 于是在这两种情况下,释放内存时使用delete或delete[]都可以,但为养成良好的习惯,我们还是应该注意只要是动态分配的数组,释放时就使用delete[]。 释放内存
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 深入 C+ new