More Effective C++之操作符operators
More Effective C++之操作符operators
- 条款5:对定制的“类型转换函数”保持警觉
- 条款6:区别increment/decrement操作符的前置(prefix)和后置(postfix)形式
- 条款7:千万不要重载&&,||和,操作符
- 条款8:了解各种不同意义的new和delete
- Placement new
- 删除(Deleteion)与内存释放(Deallocation)
- 数组(Arrays)
可加以重加载的操作符overloadable operators,它让我们所定义的类型有着和C++内建类型一样的语法,允许我们在“操作符背后的支撑函数”内放置威力强大的手段,那是内建类型不可能有的待遇。当然,“可让诸如‘+’和‘==’符号做任何事情”这个事实,也意味着我们可能利用重载操作符写出一些令人难以理解的程序。成熟的C++程序员知道如何驾驭操作符重载的强大威力,不落入令人费解的沉沦中。
可叹的是,稍有不慎,便向下沉沦。单自变量constructors及隐式类型转换操作符尤其麻烦,因为它们可以在没有任何外在迹象的情况下被调用。这可能会导致程序行为难以理解。另一类问题会在诸如&&和||等操作符重载之后发生,因为从“内建操作符”移转到“用户定制函数”所导致的各种语义敏感变化,很容易被忽略。最后,许多操作符和其他操作符之间有某种标准关系,但“操作符重载operators overloading”这个能力却使这种标准关系有被破坏的可能。
以下各个条款,我们把焦点放在“重载操作符”被调用的时机,被调用的方法、它们的行为、它们应该如何与其他操作符产生关系,以及我们如何夺取“重载操作符”的控制权。有了本章带给我们的信息,我们就可以像专家一样将“重载操作符”玩弄于鼓掌之间了。
学习书籍1;
条款5:对定制的“类型转换函数”保持警觉
C++允许编译器再不同类型间执行隐式转换(implicit conversions).继承了C的伟大传统,这个语言允许默认地将char转换为int,将short转换为double。这便是为什么我们可以将一个short交给一个“期望获得double”的函数而仍能成功的原因。C++还存在更令人害怕的转型(指的是可能遗失信息的那种),包括int转换为short,以及将double转换为char。
我们对这类转型无能为力,因为它们是语言提供的。然而当我们自己的类型登场,我们便有了更多的控制能力,因为我们可以选择是否提供某些函数,供编译器拿来作为隐式类型转换之用。
两种函数允许编译器执行这样的转换:单自变量constructors和隐式类型转换操作符。所谓单自变量constructor是指能以单一自变量成功调用的constructors。如此的constructor可能声明拥有单一参数,也可能声明拥有多个参数,并且除了第一参数之外都有默认值。下面是两个例子:
class Name {
public:Name(const string &s); // 可以将string转换为name...
};class Rational {
public:Rational(int numrator = 0, // 可以把int转换为Rational int denominator = 1);...
};
所谓隐式类型转换操作符,是一个拥有奇怪名称的member function:关键词operator之后加上一个类型名称。我们不能为此函数指定返回值类型,因为其返回值类型基本上已经表现于函数名称之上。例如,为了让Rational ojbects能够被隐式转化为doubles(这对掺杂有Rational objects的混合型算术运算可能有用),我们可能定义class Rational如下:
class Rational {
public:...operator double() const; // 将Rational转换为double
};
这个函数会在以下情况下被自动调用:
Rational r(1, 2); // r 的值为1/2
double d = 0.5 * r; // 将r转换为double,然后执行乘法运算
或许这一切对我们而言都只是复习。那很好,因为本文真正要解析的是,为什么最好不要提供任何类型转换函数。
根本问题在于,在我们从未打算也未预期的情况下,此类函数可能会被调用,而其结果可能是不正确、不直观的程序行为,很难调试。
让我们先处理隐式类型转换操作符,因为它比较容易掌握。假设我们想输出上述定义的Rational object。也就是说我们希望能够这么做:
Rational r(1, 2);
cout << r; // 预期输出1/2
更进一步假设我们忘记了为Rational写一个operator<<,那么我们或许会认为上述打印动作不会成功,因为没有适当的operator<<可以调用。但是我们错了,编译器面对上述动作,发现不存在operator<<可接受一个Rational,但是它会想尽各种办法(包括找出一系列可接受的隐式类型转换)让函数调用动作成功。“可被接受的转换程序”定义十分复杂,但本例中编译器发现,只要调用Rational::operator double,将r隐式转换为double。调用动作便能成功。于是上述代码将r隐式转换为double,调用动作便能成功。于是上述代码将r以浮点数而非分数的形式输出。这虽然不至于造成灾难却显示了隐式类型转换操作符的缺点:它们的出现可能导致错误(非预期)的函数调用。
解决办法就是以功能对等的另一个函数取代类型转换操作符。为了允许将Rational转换为double,不妨以一个名为asDouble的函数取代operator double:
class Rational {
public:...double asDouble() const; // 将Rational转换为double
};
如此的member function必须被明确调用:
Rational r(1, 2);
cout << r; // 错误!Rationals没有operator<<。
cout << r.asDouble(); // 可!以double的形式输出r。
大部分时候,“必须明白调用类型转换函数”虽然带来些许不便,却可因为“不再默默调用那些其实并不打算调用的函数”而获得弥补。一般而言,愈有经验的C++程序员愈有可能避免使用类型转换操作符。C++标准委员会中隶属标准程序库小组的那些成员,应该算是最有经验的C++程序员了吧,这或许便是为什么标准程序库的string类型并未含有“从String object至C-style char*的隐式转换函数”的原因。他们提供的办法是用一个显示的member function c_str来执行上述转换行为。巧合吗?我想不是。
通过单自变量constructors完成的隐式转换,较难消除。此外,这些函数造成的问题在许多方面比隐式类型转换操作符的情况更不好对付。
举个例子,考虑一个针对数组结构而写的class template。这些数组允许用户指定索引值的上限和下限:
template <class T>
class Array {
public:Array(int lowBound, int highBound);Array(int size);T& operator[] (int index);...
};
上述class的第一个constructor允许clients指定某个范围的数组索引,例如10~20。身为一个双自变量constructor,此函数没有资格成为类型转换函数。第二个constructor允许用户只指定数组的元素个数,便可定义出Array objects(这很类似内建数组)。它可以被用来作为一个类型转换函数,结果导致无尽苦恼。
例如,考虑一个用来对Array<int>对象进行比较动作的函数,以及调用示例:
bool operator==(const Array<int>& lhs,const Array<int>& rhs);
Array<int> a(10);
Array<int> b(10);
...
for (int i = 0; i < 10; ++i) if (a == b[i]) { // 哎呀!“a”应该是“a[i]”才对do something for whena[i] and b[i] are equal;} else {do something for when they're not;}
我们试图将a的每一个元素拿来和b的对应元素比较,但是当我们键入a时却意外地遗漏了下标(方括号)语法。我们当然希望编译器发挥挑错功能,将它挑出来,但它却一声也不吭。因为它看到是一个operator==函数调用,夹带着类型为Array<int>的自变量a和类型为int的自变量b[i],虽然没有这样的operator==函数可被调用,编译器却注意到,只要调用Array<int> constructor(需一个int作为自变量),它就可以将int转为Array<int>object。因而产生类似这样的代码:
for (int i = 0; i < 10; ++i)if ( a == static_cast<Array<int> >(b[i])) ...
于是,循环的每一次迭代都拿a的内容来和一个大小为b[i]的临时数组(其内容想必未定义)做比较。此种行为不仅不令人满意,而且非常没有效率。因为每次走过这个循环,我们都必须产生和销毁一个临时的Array<int>object。
只要不声明隐式类型转换操作符,便可将它所带来的害处避免。但是单自变量constructors却不那么容易去除,毕竟我们可能真的需要提供一个单自变量constructors给我们的client使用。与此同时,我们也可能希望组织编译器不分青红皂白地调用这样的constructors。幸运的是有一种(事实上是两种)做法可以两者兼顾:一个是简易法,另一个可在编译器不支持简易法的情况下使用。
简易法是使用C++的特性:关键词explicit。这个特性之所以被导入,就是为了解决隐式类型转换带来的问题。其用法十分直接易懂,只要将constructors声明为explicit,编译器便不能因隐式类型转换的需要而被调用它们。不过显示转型仍是允许的:
template <class T>
class Array{
public:
explicit Array(int size); // 注意使用了explicit
};
Array<int> a(10);
Array<int> b(10);
if (a == b[0]) ... // 编译错误!无法将int隐式转换为Array<int>if (a == Array<int>(b[0])) ... // 显示将int转换为Array<int>比较没问题if (a == static_cast<Array<int> >(b[0])) ... // 显示将int转换为Array<int>比较没问题if (a == (Array<int>)b[0]) ... // C旧式转型也没问题
如果编译器尚不支持关键词explicit,我们就得走回头路,通过以下做法组织单自变量constructors成为隐式类型转换函数。隐式类型转换程序有着复杂的游戏规则。其中一条规则是:没有任何一个转换程序可以内含一个以上的“用户定制转换行为(亦即单自变量constructor或隐式类型转换操作符)”。为了适当架构起我们的class,我们可以利用这项规则,让我们希望拥有的“对象构造行为”合法化,并让我们不希望允许的隐式构造非法化。
让我们再次考虑Array template。我们需要一种方法,不但允许以一个整数作为constructor自变量来制定数组大小,又能阻止一个整数被隐式转换为一个临时性Array对象。于是我们首先产生一个新的class,名为ArraySize。此型对象只有一个目的:用来表现即将被产生的数组的大小。然后我们修改Array的单自变量constructor,让它接受一个ArraySize对象,而非一个int。代码如下:
template<class T>
class Array{
public:
class ArraySize {
public:ArraySize(int numElemets): theSize(numElements) {}int size {return theSize;}
private:int theSize;
};Array(int lowBound, int highBound);
Array(ArraySize size);
...
};
在这里,把ArraySize嵌套放进Array内,强调一个事实:它永远与Array搭配使用。我们也把ArraySize放在Array的public区,使任何人都能使用它。好极了!
现在考虑当我们通过Array的“单自变量constructor”定义一个对象时,会发生什么事:
Array<int> a(10);
我们的编译器要求调用Array<int> class中的一个自变量为int的constructor,但其实并不存在这样的constructor。编译器知道它能够将int自变量转换为一个临时的ArraySize对象。而该对象正是Array<int> constructor需要的,所以编译器便依其所号执行了这样的转换。于是函数调用(以及随附的对象构造行为)得以成功。
“以一个int自变量构造起一个Array对象”这个事实依然可靠有效。但除非“我们希望避免类型转换动作”确实被阻止,否则那也算不上是什么好消息。是的,它们的确被阻止了。再次考虑这段代码:
bool operator==(const Array<int>& lhs,const Array<int>& rhs);
Array<int> a(10);
Array<int> b(10);
...
for (int i = 0; i < 10; ++i) if (a == b[i]) // 哎呀!“a”应该是“a[i]”才对
编译器需要一个类型为Array<int>的对象在“==”的右手边,得以针对Array<int>对象调用operator==,但是此刻并没有“单一自变量,类型为int”这样的constructor。此外,编译器不能考虑将int转换为一个临时性的对象ArraySize对象,然后再根据这个临时对象产生必要的Array<int>对象,因为那将调用两个用户定制转换行为,一个将int转换为ArraySize,另一个将ArraySize转换为Array<int>。如此的转换程序是禁止的,所以编译器对以上代码发出错误消息。
本例对ArraySize class的运用,或许看起来是特殊安排的情况,但它其实是一种更一般化的技术的一个特别实例。类似于ArraySize这样的class,往往被称为proxy class,因为它的每一个对象都为了其他对象而存在,好像其他对象的代理人(proxy)一般。ArraySize对象只是“用来指定Array大小”的整数替身而已。Proxy objects让我们得以超越外观形式(本例为隐式类型转换),进而控制我们的软件行为,是值得学习的一项技术。后续章节会有更进一步的说明。
总结:允许编译器执行隐式转换,害处将多过好处。所以不要提供转换函数,除非我们确定需要它们;
学习心得:
为了避免不可预期的隐式转换,对单参数构造函数增加explicit的修饰,如果编译器暂时不支持explicit关键字,则使用一个proxy class,将单参数的构造函数,需要经过两轮构造函数的构造才能转换为目标对象,从而利用规则实现对隐式转换行为的禁止。
条款6:区别increment/decrement操作符的前置(prefix)和后置(postfix)形式
很久很久以前(大约20世纪80年代后期),在一个遥远的语言(当时的C++)中,没有什么办法可以区分++和--操作符的前置式(prefix)和后置式(postfix)。
但程序员毕竟是程序员,他们动不动就对此情况发牢骚,于是C++决定扩充,允许++和--操作符的两种形式(前置式和后置式)拥有重载能力。
这时候出现了一个语法上的问题:重载函数是以其参数类型来区分彼此的,然而不论increment或decrement操作符的前置式或后置式,都没有参数。为了填平这个语言学上的漏洞,只好让后置式有一个int自变量,并且在它被调用时,编译器默默地为该int指定一个0值:
class UPInt {
public:UPInt& operator ++ (); // 前置式++const UPint operator++(int); // 后置式++UPInt& operator -- (); // 前置式--const UPint operator--(int); // 后置式--UPInt& operator +=(int); // +=操作符,结合UPInts和ints。...
};
UPInt i;
++i; // 调用i.operator++()
i++; // 调用i.operator++(0)
--i; // 调用i.operator--()
i--; // 调用i.operator--(0)
这样的规则或许有点怪异,但我们很快就会习惯。重要的是,那些操作符的前置式和后置式返回不同的类型,前置式返回一个reference,后置式返回一个const对象。以下我们集中讨论++操作符的前置式和后置式,至于--操作符,故事一样。
从C的时代回忆起,我们或许还记得所谓increment操作符前置式意义"increment and fetch"(累加然后取出),后置式意义“fetch and increment”(取出然后累加)。这两个词组值得记录下来,因为它们几乎成为前置式和后置式increment操作符应该如何实现的正式规范:
// 前置式:累加然后取出(increment and fetch)
UPInt& UPInt::operator++() {*this += 1; // 累加(increment)return *this; // 取出(fetch)
}// 后置式:取出然后累加(fetch and increment)
const UPInt UPInt::operator++(int) {UPInt oldValue = *this; // 取出(fetch)++(*this) ; // 累加(increment)return oldValue; // 返回先前取出的值
}
请注意后置式操作符并未动用其参数。其参数的唯一目的只是为了区别前置式和后置式而已。
为什么后置式increment操作符必须返回一个对象(代表旧值),原因很清楚。但为什么是个const对象呢?想象一下,如果不这样,以下动作是合法的:
UPInt i;
i++++; // 实施“后置式increment操作符”两次
i.operator++(0).operator++(0); // 与上述操作等价
可以看出operator++的第二次调用动作施行于第一个调用动作的返回对象身上。
两个理由是我们不欢迎这样的情况。第一,它和内建类型的行为不一致。设计classs的一条无上宝典就是:一旦有疑虑,试看ints行为如何并遵循之。我们知道,ints并不允许连续两次使用后置式increment操作符:
int i;
i++++; // 错误! (++++i则合法)
第二个理由是,即使能够两次施行后置式increment操作符,其行为也非我们所预期。一如上述所示,第二个operator++所改变的对象是第一个operator++返回的对象,而不是原对象。因此即使下式合法:
i++++;
i也只被累加一次而已。这是违反直觉的,也容易引起混淆(不论是对ints或UPInts),所以最好的办法就是就是禁止它合法化。
C++针对ints禁止了上述行为,而我们则必须针对所涉及的class自行动手加以禁止。最简单的做法就是让后置式increment操作符返回一个const对象。于是当编译器看到:
i++++; // 视同i.operator ++(0).operator ++(0);
它便认知到,第一次调用operator++所返回的const对象,将被用来进行operator的第二次调用。然后operator++是个non-const member function,所以const对象(亦即本例的后置式operator++返回值)无法调用之。但是,不执行这项限制的编译器也是有所闻。如果我们依赖此性质撰写程序,请先测试编译器以确定它有正确的行为。如果我们曾困惑“另函数返回const对象是否合理”。现在我们知道了:有时候的确需要如此,后置式increment和decrement操作符就是个例子。
如果我们担心效率问题,当我们初次看到后置式increment函数,或许会头冒冷汗。该函数必须产生一个临时对象,作为返回值之用。上述实现代码也的确产生了一个明显的临时对象(oldValue),需要构造也需要析构。前置式increment函数就没有如此的临时对象。这导致一个令人吃惊的结论,单一效率因素而言,UPInt的用户应该喜欢前置式increment多过喜欢后置式increment,除非真的需要后置式increment行为,让我们把话说清楚,处理用户定制类型时,应该尽可能使用前置式increment,因为它天生体质较佳。
现在让我们对increment操作符的前置式和后置式更进一步观察。除了返回值之外,他们做相同的事情:将某值累加。那么,我们如何确定后置式increment和前置式increment的行为一致?我们如何保证它们的实现代码不会因时间而分道扬镳?说不定不同的程序员对它们分别做了不同的维护和强化。除非遵守上述代码所表现的设计原则,否则将毫无保障。那个原则是:后置式increment和decrement操作符的实现应以其前置式兄弟为基础。如此一来我们就只需要维护前置版本,因为后置式版本会自动调整为一致的行为。
如你所见,掌握increment和decrement操作符符的前置式和后置式是很容易的。一旦我们知道它们应该返回什么类型,以及后置操作符应该以前置式操作符为基础,就几乎没有什么更高阶的知识需要学习了。
学习心得
前置式和后置式++/--,当中效率前置式通常都是效率更好的那一个,优先调用它;同时后置式在实现设计上需在内部调用前置式操作符,以保证二者的行为的一致,不会因为多人维护而导致行为的偏离;同时为了避免一些操作符的歧义(比如i++++),后置运算符的返回值必为const value,为了效率及连续运算(++++i)前置运算符的返回结果为reference。
条款7:千万不要重载&&,||和,操作符
和C一样,C++对于“真假值表达式”采用所谓的“骤死式”评估式。意思是一旦表达式的真假值确定,即使表达式中还有部分尚未检验,整个评估工作即告结束。举个例子,下面的情况:
char *p;
...
if ((p!=0)&& (strlen(p) > 10) ...
我们无需担心调用strlen时p是否为null指针,因为如果“p是否为0”的测试结果为否定,strlen就不会被调用。同样的道理以下代码:
int rangeCheck(int index) {if ((index < lowerBound) || (index > upperBound)) ......
}
如果index小于lowerBound,它就绝不会被拿来和upperBound比较。
这是C/C++社区人尽皆知的一个行为,其年代已经古老的不复记忆。这是他们预期而毫不犹豫的行为。甚至他们所写的程序必须依赖这种“骤死式”评估方式才能表现出正确行为。例如,上一段代码所依赖的一个重要事实是,如果p是null指针,strlen不会被调用,因为对C++ standard(以及C standard)来说,对一个null指针调用strlen,结果不可预期。
C++允许我们为“用户定制类型”量身定做&&和||操作符。做法是对operator&&和operator||两函数进行重载工作。我们可以在global scope或是在每个class内做这种事儿。然后如果我们决定运用这个机会,必须知道,我们正从根本层面改变整个游戏规则,因为从此“函数调用语义”会被取代“骤死式语义”,也就是说,如果我们将operator&&重载,下面这个表达式
if (expression1 && expression2) ...
会被编译器视为
// 假设operator && 是个member function
if (expression1.operator&&(operator2) ) ...
// 假设operator &&是个全局函数
if (operator&&(expresion1, expression2)) ...
这看起来没什么大不了,但是“函数调用”语义和所谓“骤死式”语义有两个重大的区别。第一,当函数调用动作被执行,所有参数值都必须评估完成,所以当我们调用operator&&和operator ||时,两个参数都已评估完成。换句话说没有什么骤死式语义。第二,C++语言规范并未明确定义函数调用动作中各参数的评估顺序,所以没办法知道expression1和expression2哪个会先被评估。这与骤死式评估形式形成一个明确的对比,后者总是由左至右评估其自变量。
所以,如果我们将&&或||重载,就没有办法提供程序员预期(甚至依赖)的某种行为模式,所以请不要重载&&或||。
逗号(,)操作符情况类似;
逗号操作符用来构造表达式,我们应该已经在for循环中更新区(update part)见过此物。
// 将字符串s的字符顺序颠倒
void reverse(char s[]) {for (int i = 0, j = strlen(s) - 1;i < j;++i, --j) // 此处用到了逗号操作符{char c = s[i];s[i] = s[j];s[j] = c;}
}
在这里,for循环的最后一个成分中,i被累加二j被递减。这里很适合使用逗号操作符,因为for循环的最后一个成分是个表达式(expression);如果用个别语句(statements)来改变i和j的值,是不合法的。
C++有一些规则用来定义&&和||面对内建类型的行为,C++同样也有一些规则用来定义逗号操作符面对内建类型的行为。表达式如果内含逗号,那么逗号左侧先被评估,然后逗号的右侧再被评估;最后,整个逗号表达式的结果以逗号右侧的值为代表。所以面对上述循环的最后一个成分,编译器首先评估++i,然后是--j,而整个逗号表达式的结果是--j的返回值。
或许我们会奇怪为什么需要知道这些。因为如果我们打算撰写自己的逗号操作符,就必须模仿这样的行为。不幸的是,我们无法执行这些必要的模仿。
如果我们把操作符写成一个non-member function, 我们绝对无法保证左侧表达式一定比右侧表达式更早被评估,因为两个表达式都被当做函数调用时的自变量,传递给该操作符函数,而我们无法控制一个函数的自变量评估顺序。所以non-member做法不可行。
唯一剩下的可能是将操作符写成member function。但即便如此我们仍然不能保证逗号操作符的做操作数先被评估,因为编译器并不强迫做这样的事情。因此,我们无法“不但将逗号操作符重载,并保证期行为像它应该有的那样”。所以不要轻率地将它重载。
我们或许会疑惑,重载的疯狂行为到底有没有底线?毕竟,如果可以将逗号运算符重载,还有什么是不能重载的呢?事实上底线存在。我们不能够重载以下操作符:
. * :: ?:
new delete sizeof typeid
static_cast dynamic_cast const_cast reinterpret_cast
我们可以重载的操作符有:
operator new operator delete
operator new[] operator delete[]
+ - * / % ^ & | ~
! = < > += -= *= /= %=
^= &= |= << >> >>= <<= == !=
<= >= && || ++ -- , ->* ->
() []
(关于new和delete operators,以及operator new,operator delete,operator new[]和operator delete[] 下节将会阐述)
只因为可以重载这些操作符,就毫无理由地去进行,是没有道理的。操作符重载的目的是让程序更容易被阅读、被撰写、被理解,不是为了向别人夸耀知道“逗号其实是个操作符”。如果我们没有好理由将某个操作符重载,就不要去做。面对&&,||和,实在难有什么好理由,因为不管我们多么努力,就是无法令其行为像他们应有的行为一样。
学习心得:
本节介绍了C/C++针对内置操作符&&及||有特殊的“骤死式”语义,亦即我们通常理解的语法短路,逻辑与遇上第一个表达式为false时,就不会去计算第二个表达式;逻辑或,遇到第一个表达式为true时,就不会去计算第二个表达式。然后当我们重载逻辑&&或||时,以上的逻辑短路语义将无法达成;同时如果重载逗号表达式,会存在无法满足表达式原有的从左到右的计算顺序。所以本主题建议我们;禁止针对操作符&&、||和,进行重载。
条款8:了解各种不同意义的new和delete
有时候我们觉得,C++的属于仿佛是要故意让人难以理解似的。这里就有一个例子:请说明new operator和operator new之间的差异。
当我们写出这样的的代码:
string *ps = new string("More effective C++");
我们使用的new是所谓的new operator。这个操作符是由语言内建的,就像sizeof那样,不能被改变语义,总是做相同的事情。它的动作分为两个方面。第一它分配足够的内存,用来放置某类型的对象。以上例而言,它分配足够放置一个string对象的内存。第二,它调用一个constructor,为刚才分配的内存中的那个对象设定初值。new operator总是做这两件事,无论如何我们不能改变其行为。
我们能够改变的是用来容纳对象的那块内存分配行为。new operator调用某个函数,执行必要的内存分配动作,我们可以重写或重载那个函数,改变其行为。这个函数的名称叫做operator new。
函数operator new通常声明如下:
void * operator new(size_t size);
其返回值类型为void*。此函数返回一个指针,指向一块原始的、未设初值的内存(如果我们乐意,可以写一个新版的operator new,在其返回内存指针之前先将那块内存设定初值。只不过这种行为颇为罕见就是了)。函数中的size_t参数表示需要分配多少内存。我们可以将operator new重载,加上额外的参数,但是第一参数的类型必须总是size_t。
或许我们从未想到要直接调用operator new,但如果我们想想调用任何其他函数一样调用它:
void * rawMemory = operator new (sizeof(string));
这里的operator new将返回指针,指向一块足够容纳一个string对象的内存。
和malloc一样,operator new的唯一任务就是分配内存。它不知道什么是constructors,operator new只负责内存分配。取得operator new返回的内存并将之转换为一个对象,是new operator的责任。当编译器看到这样一个句子:
string *ps = new string("More effective C++");
它必须产生一些代码,或多或少会反映如下:
void * memory = operator new (sizeof(string));
call string::string("More effective C++") on *memory;
string *ps = static_cast<string*> (memory)
Placement new
有些时候,我们真的会想直接调用一个constructor。针对一个已存在的对象调用其constructor并无意义,因为constructors用来将对象初始化,而对象只能被初始化一次。但是偶尔我们会有一些分配好的原始内存,我们需要在上面构造对象。有一个特殊版本的operator new,成为placement new,允许我们那么做。示例如下:
class Widget{
public:Widget(int widgetSize);...
};
Widget * constructWidgetInBuffer(void *buffer, int widgetSize) {return new (buffre) Widget(widgetSize);
}
此函数返回指针,指向一个Widget object,它构造与传递给此函数的一块内存缓冲区上。当程序运行到shared memory或memeory-mapper I/O,这类函数可能是有用的,因为在那样的运用中,对象必须置于特定地址,或是置于以特殊函数分配出来的内存上
在函数constructWidgetInBuffer函数内部,唯一一个表达式是:
new (buffre) Widget(widgetSize)
咋看之下,其实不足为奇,这只是operator new的用法之一,其中指定一个额外自变量(buffer)作为new operator“隐式调用operator new”时所用。于是,被调用的operator new除了接受“一定得有的size_t自变量”之后,还接受一个void*参数,指向一块内存,准备用来接受构造好的对象。这样的operator new就是所谓的placement new,看起来像这样:
void * operator new(size_t, void *location) {return location;
}
似乎比我们预期得更简单,但这边是placement new必须做的一切。毕竟operator new的目的是要为对象找一块内促,然后返回一个指针指向它。在palcement new的情况下,调用者已经知道指向内存的指针了,因为调用者知道对象应该放在哪里。因此placement new唯一需要做的就是将它获得的指针再返回。欲使用placement new,我们必须用#incude<new>。
花几分钟回头想想placement new,我们便能了解new operator和operator new之间的关系,两个术语虽然表面上令人迷惑,概念上却十分直接易懂。如果我们希望将对象产生于heap,请使用new operator。它不但分配内存而且为该对象调用一个constructor。如果知道算分配内存,就调用operator new,那就没有任何constructor会被调用。如果打算在heap objects产生时自己决定内存分配方式,请写一个自己的operator new,并使用new operator,它会自动调用重载过后的operator new。如果打算在已分配(并拥有指针)的内存中构造对象,请使用placement new。
删除(Deleteion)与内存释放(Deallocation)
为了避免resource leaks(资源泄露),每一个动态分配行为都必须匹配一个相应但相反的释放动作。函数operator delete对于内建delete operator,就好像operator new对于new operator一样。当我们写出这样的代码:
string *ps;
...
delete ps; // 使用delete operator。
我们的编译器必须产生怎样的代码?它必须既能够析构ps所指的对象,又能够释放被该对象占用的内存。
内存释放动作是由函数operator delete执行,通常声明如下:
void operator delete(void *memoryToBeDeallocated);
因此,下面这个动作:
delete ps;
编译器会产生近似这样的代码
ps->~string();
operator delete(ps);
这里呈现的一个暗示就是,如果我饿么只打算处理原始的,未设初值的内存,应该完全回避new operator和delete operator,改用operator new取得内存并以operator delete归还给系统:
void *buffer = operator new(50*sizeof(char)); // 分配大小为50的内存块,未调用任何ctors
...
operator delete(buffer); // 释放内存未调用任何dtors
这组行为在C++中相当于调用malloc和free。
如果我们使用placement new,在某内存块中产生对象,我们应该避免对那块内存使用delete operator。因为delete operator会调用operator delete来释放内存。但是该内存包含的对象最初并非是由operator new分配得来的。毕竟placement new只是返回它所接收的指针而已,谁知道按个指针哪里来呢?所以为了抵消该对象constructor的影响,我们应该直接调用该对象的destructor:
// 以下函数用来分配和释放shared memory中的内存
void * mallocShared(size_t size);
void freeShared(void *memmory);void * sharedMemory = mallocShared(sizeof(Widget)); // 和先前相同,运用
Widget *pw = constructWidgetInBuffer(sharedMemeory, 10); // placement new
...
delete pw; // 无定义!因为shareMemory来自mallocShared,不是来自operator newpw->~Widget(); // 可!析构pw所指的Widge对象,// 但为释放所占用的内存
freeShare(pw); // 可!释放pw所占用的内存// 不调用任何destructor。
如此例所示,如果交给placement new的原始内存(raw memory)本身是动态分配而得(通过某种非传统做法),那么我们最终还是得释放那块内存,以免遭受内存泄漏(memory leak)之苦。
数组(Arrays)
目前一切都好,但我们还有更远的路要走。截至目前我们考虑的每件事情都只在单一对象身上打转。面对数组怎么办?下面会发生什么事情:
string *ps = new string[10]; // 分配一个对象数组
上述使用的new仍然是那个new operator,但由于诞生的是数组,所以new operator的行为与先前产生单一对象的情况略有不同。内存不再以operator new分配,而是尤其“数组版”兄弟,一个名为operator new[]的函数负责分配(通常被称为“array new”)。和operator new一样,operator new[]也可以被重载。这使得我们夺取数组的内存分配权,就像我们可以控制单一对象的内存分配一样。
“数组版”与“单一对象版”的new operator的第二个不同是,它所调用的constructor数量。数字版new operator必须针对素组中的每个对象调用一个constructor:
// 调用operator[]分配足够容纳10个string对象的内存,
// 然后针对每个元素调用string default ctor
string *ps = new string[10];
同样的道理,当delete operator被用于数组,它会针对数组中的每个元素调用destructor,然后再调用operator delete[]释放内存:
// 为数组中的每个元素调用string dtor,
// 然后调用operator delete[] 以释放内存
delete[] ps;
就好像我们可以取代或重载operator delete一样,我们也可以取代或重载operator delete []。不过两者的重载有着相同的限制。
现在,我们有了完整的知识。new operator和delete operator都是内建操作符,无法为我们所控制,但是他们所调用的内存分配/释放函数则不然。当我们想定制new operator和delete operator的行为,记住,我们起始无法真正办到。我们可以修改它们完成任务的方式,至于它们的任务,已经被语言规范固定死了。
学习心得
对于new operator是C++固定的操作符,不可被重载;new operator默认会调用用operator new申请内存,该操作符我们可以重载,从而为我们自定义内存申请方式提供了可能,比如自己申请一块大的内存作为memory pool进行申请和释放(例如STL中simple_alloc)具体可参考STL之空间配置器allocator;或者将内存的申请转移到大页内存等。有了operator new 重新申请内存后,可以基于该块申请的内存,构造C++ class object,此时placement new操作符就派上了用场,在指定的内存位置上,进行对象的构造。
delete operator与new operator形成一个镜像操作。首先调用对象的析构函数,而后调用operator delete,如果此前使用了重载过后operator new申请内存,必然需要重载operator delete与前者进行对应,以此保证内存使用的有序性(不至于导致内存泄漏)。
operator new[] 及operator delete[]的操作方法与operator new及operator delete操作方式雷同。
1: More Effective C++: 35个改善编程与设计的有效方法/(美)梅耶(Meyers’s.)著;侯捷译.北京:电子工业出版社,2011.1 ↩︎