C++之solmyr小品文
画外音:今天是个大晴天,温暖的阳光透过窗子照进了这间宽敞的办公室,办公室里三三两两的人们正在各自的计算机前努力工作,一切都显得那么的安静、祥和、有条不紊 ……
“啊~!救命啊!Solmyr 你又用文件夹砸我!”
“愚蠢者是应该受到惩罚的。”
画外音: …… 呃,好吧,我得承认有点小小的例外。这里是一家软件公司,发出惨叫的这位是 zero ,新进的大学生;这边一脸优雅,看上去很有修养一点也不象刚刚砸过人的这位,是 Solmyr ,资深程序员,负责 zero 这一批新人的培训。啊,故事开始了 ……
“我干了什么啦?”zero 揉着鼻子问道,“这次你拿来砸我的文件夹又大了一号!”
“你过来自己看看你犯下的错误。”Solmyr 翻出了 zero 刚刚交上来的一段代码:
…… char* msg = “Connectting ... Please wait“ …… if( Status == S_CONNECTED ) strcpy(msg, “Connectted“); ……
“我犯了什么错误啦?这是一个很平凡的字符串声明而已”,zero 不满的说到。
“你看不出来吗?connect 这个单词的进行时和过去时你都拼错了,多打了一个 t”,Solmyr 不紧不慢地回答。
“就为了这个你又用文件夹砸我 …… 啊!这次又是光盘盒!”
“这是商用软件,你以为是在 QQ 上和 PPMM 聊天,有错别字不要紧啊?更糟糕的是,我故意留了这么长的时间给你,到现在你还没发现你真正的错误在什么地方。你可真不是一般的菜啊~”,Solmyr 故意拖了个长音,满意的看到 zero 处于爆发的边缘,“好吧,让我们从基础开始,C 语言中是怎样处理字符串的?”
“这个我知道”,zero 显得很有自信,“C/C++ 语言中,字符串是一段连续的字符型内存单元,每个单元存放一个字符,并用 \0 作为结尾的标记。”
“那么使用指针之前,我们应当 ……”
“我们应当保证这个指针指向合法的内存,要么指向一块已经存在的内存,要么为它动态分配一块。”,zero 开始露出得意的笑容 —— 这种程度的问题,哈!
“好!那么你的代码中 msg 这个指针指向哪里?”
笑容凝固了。
“这个 …… 呃 …… 我想 …… 它应该指向一块合法内存,因为以前我这么做的时候,它能工作 ……”,zero 期期艾艾的说。
“合法内存?这块内存是谁分配的?它有多大?生存周期多长?有哪些特殊的性质?”
“……”
“唉!”,Solmyr 重重的叹了口气,“我就知道会这样。好吧,让我们先从简单的开始。”。Solmyr 飞快的键入了如下代码:
char msg[] = “Hello“; char* pmsg = (char*)malloc( sizeof(“Hello“) ); strcpy(pmsg, “Hello“);
“上面这些代码你应该都很清楚了:msg 是一个字符数组,C 语言保证会为它分配一段连续的内存,并将其初始化为 “Hello“ 。pmsg 是一个字符指针,我们调用了 malloc 函数为它动态分配了一块内存,并用 strcpy 函数填充其值为 “Hello“ 。这两种做法的共通点是:首先用正常手段获得一段内存,然后填充值。接着再来看这个:”
char* msg = “Hello“;
“这一句代表什么意思?首先 msg 是个指针,C/C++ 语言不负责为它分配一块内存;其次我们也没有显式的为它分配一块内存。它指向哪里?指向 “Hello“ ,就是你直接写在代码里的那一个。”
“什么叫做‘直接写在代码里的那一个’?”,zero 露出了困惑的表情
“举个例子你就明白了:”,Solmyr 再键入:
double db = 1.5;
“这一行里面,1.5 是个什么东西?它是一个 double 类型常量,C/C++ 语言要处理它们,也要分配内存来存放这些东西。同理,当你在代码里写了 “Hello“ ,实际上 C/C++ 语言就分配了一块内存存放这个字符串,当你写 char* msg = “Hello“ 的时候,你就是把这样一块内存的地址赋给了指针 msg 。所以 msg 确实指向一块合法内存,这是有时候这段代码能够工作的原因。但是这样做,其中蕴涵了许多问题,我来问你,指向这块内存的指针应该是什么类型?”
“当然是 char*”,zero 不加思索的回答。
“错!应该是 const char* 。想当然耳,写在程序中的字符串你不希望它发生变化,所以很明显的,这块内存应该被解释为常量。但是你在声明 msg 的时候做了什么?”
“呃 …… 我用了一个非常量的指针去指向了一个常量字符串。”,这一次,zero 明显的审慎多了。
“正确。看你原来的代码,你不仅用一个非常量指针指向它,而且还对这个指针执行了 strcpy ,往里写了内容。在我们的编译器上,这么做会引发什么后果?”
“呃 …… 引发一个运行时错误?”
“部分正确。准确的讲,只有在工程编译选项为调试版本的时候,如果工程编译选项为发布版本,一切都很正常 —— 奇怪吗?并不,记住这一点:C/C++ 允许你打破任何保护。所以如果这两行代码在调试的时候没有被发现而溜进了发布版本里”,说到这,Solmyr 狠狠的瞪了 zero 一眼,“将会是很难发现的。”
“可是说来说去这么做还是没有什么危害不是吗?msg 指向一块合法内存,内容正确,而且也并不是真的不能写入,有什么好担心的呢?”,zero 抱怨道。
Solmyr 顺手抓起杯子,zero 反射性的立刻缩头护脸。“别担心,我只是喝水而已。”,Solmyr 面无表情 —— 如果忽略他嘴角那一丝坏笑的话 —— 的说到,“没有危害是吗?看看下面的代码:”
char* str1 = “Hello“; char* str2 = “Hello“; *str1 = ‘P‘; cout << str2 << endl;
“猜猜运行结果是什么?”,Solmyr 一边调整工程设置,一边问道。
“这还用问吗?当然是输出 Hello 了。”
“回答错误,正确答案是 ……”,Solmyr 按下了运行按钮,屏幕显示的居然是 Pello !。
zero 大为诧异,挠着头试图找出其中的逻辑,突然间灵光一闪:“我明白了!str1 和 str2 实际指向同一段内存!因为 C/C++ 语言在处理 Hello 字符串的时候把它当作常量,所以就做了优化,只保存了一份 Hello !是不是这样!”zero 兴奋的转向 Solmyr。
“嗯,看起来有时候你也不是那么菜么”,Solmyr 赞许的点头,“不过你还是说错了一点:这个不是 C/C++ 语言的做法,是这个编译器的做法。简单的说,你如果要对这种字符串写的话,其结果如何,是没有定义的。所谓没有定义,就是 C/C++ 语言不保证会得到怎样的结果,可能这样也可能样,完全决定于你的编译器作者怎么想。想想看吧,哪天你的程序出现了古怪的问题 —— 比如显示信息出现了混乱 —— 起因却是你在无关的地方写了一个字符串,会怎样?这是维护时最大的恶梦之一。现在你明白危害在哪里了?”
zero 有如大梦初醒一般忙不迭地点头:“我知道了,我知道了。”
“知道了还不快去改!”
……
zero 跑回坐位修改他的程序去了,办公室里再度恢复了宁静,所有的人都埋头于他们的工作之中。只有 Solmyr 一边喝着咖啡一边揉着太阳穴,喃喃地吐出不祥的词句:“这样的日子才刚刚开始啊 ……”
Elminster
“为什么会这样?!”,zero 一边喝水一边嘟囔着,恨恨的看着面前显示器上的代码,“为什么这么简单的一个调用也会出现编译错误 …… ”
“这是因为你的设计太差!”
噗!zero 被幽灵一样出现在背后的 Solmyr 吓了一大跳,一口水差点全喷出来。
“咳!咳咳!S …… Solmyr ,你什么时候站在我背后的?”,zero 很费力的平息了咳嗽,同时努力回想刚才自己有没有把柄会被 Solmyr 抓到。
Solmyr 抓过一张椅子坐了下来:“在你一开始干傻事的时候我就在了,正是这个糟糕的设计导致了现在困扰你的编译错误。”
“哪 …… 哪里?”
“这儿。” Solmyr 抓过键盘,标出了下面这段代码:
void SomeFunc(int i) ………… void SomeFunc(float f) ………… int main() { ………… SomeFunc(1.2);// Error! ambiguous call ………… }
“我也正觉得奇怪”,zero 一如既往的挠着头,试图压榨不存在的智慧,“这么简单的一个函数重载,应该很清楚才对。我这里调用时明明给出的是浮点数,显然应该调用 float 版本的 SomeFunc 。最奇怪的是如果没有这个调用,整个程序编译连接完全没有问题,可见这样重载函数是合法的。”
“嗯,没错,确实是合法的,但是合法不代表正确。zero ,你念一下这一段,看看先知 Meyers 在他的《50 诫》(注:指《Effective C++ 2/e》一书)中的条款 26 中是怎样描述 C++ 对待‘模棱两可’的哲学的。”,Solmyr 翻开了一本书,指着其中的几行。
“C++ ……”
“站起来,大声念!”
zero 依言站起,中气十足的念道:“C++ 也有一个哲学信仰:它相信潜在的模棱两可的状态不是一种错误。”
旁边的座位上传来低低的窃笑声,更远处的人探头张望,投来好奇的目光,zero 顿时感到自己像个傻瓜。当 zero 看到 Solmyr 嘴边招牌式的坏笑时明白了过来:自己又一次被 Solmyr 设计了。
“嗯,明白了这一点,我们就可以展开进一步的讨论了”,Solmyr 开始转入正题,“还记得上次我说过上面的 1.2 是什么吗?”
zero 露出了回忆的表情:“嗯 …… 1.2 是‘写在代码里的常量’…… 应该是一个 double 类型常量。”
“这就是问题所在:编译器看到这个调用函数的请求,会去寻找你的重载函数中哪个函数能够匹配这个调用请求给出的参数,结果它发现没有一个函数的参数是 double 类型的,所以必须要做类型转换,但是 double 类型既可以转成 int ,也可以转成 float ,究竟转哪个好呢?编译器不知道,所以只好报错了。明白了吗?”
zero 似懂非懂的点了点头。
“那我问你,这样重载编译时会不会报错?”,Solmyr 稍稍改动了一下 zero 的代码:
void SomeFunc(int i) ………… void SomeFunc(double db) ………… int main() { ………… float f = 1.2; SomeFunc(f); ………… }
zero 看了看,学着 Solmyr 的语气说到:“编译器发现没有一个函数的参数是 float 类型的,所以必须要做类型转换,但是 float 类型既可以转成 int ,也可以转成 double ,究竟转哪个好呢?编译器不知道,所以只好报错了。”
“错!”,Solmyr 顺手按下了运行按钮,程序运行一切正常,输出显示调用的是 double 版本的 SomeFunc 函数。
zero 再度感到了困惑:“为什么同样是要选择类型转换,这个就没错,前一个就有错呢?这中间的逻辑何在?”
“重要的是这一句:‘究竟转哪个好呢?编译器不知道’。你没有注意到我说这句话的时候‘好’字上用了一个重音吗?”
“你用过重音吗?”
“ …… 这个不是重点。重点在于,float 到 int 和 float 到 double 这两个转换,编译器是能够选择的,因为 float 到 int 会损失数据 —— 象样的编译器会在做这种类型转换的时候给出一个 warning —— 而 float 到 double 则不损失数据,所以编译器知道‘转哪个好’。而之前的情况,double 到 int 到 float 的转换都要损失数据,所以编译器不知道‘转哪个好’,它没办法做一个决定 —— ”,Solmyr 看了看 zero ,再度问道,“明白了吗?”
zero 皱着眉头,挠头挠的更起劲了,显然对于消化一下子出现的这么多信息感到少许困难:“我想我明白了,关键是编译器能否区分两个类型转换。在这里区分的关键是类型转换是否损失数据,嗯 …… 所以我只要在所有用到浮点数的场合都使用 double 类型,就不会有问题,即使别人用 float 来调用也一样。”
“正确。不过‘模棱两可’的问题可不仅仅出现浮点数身上,例如,这样两个重载函数 …… ”,Solmyr 接着键入:
void SomeFunc(double db) void SomeFunc(char ch)
“如果我用一个整形变量来调用,会出现什么事情?”,Solmyr 扭头盯着 zero。
“呃 …… 编译器同样无法区分 int 到 double 和 int 到 char 这两个类型转换,所以同样会报错。”
“正确。你能够自己举出几个例子吗?”,Solmyr 把键盘递了回去。
很明显的,zero 陷入了沉思,过了一会儿,屏幕上出现了这样几行代码:
// 用 int 调用的话会出错 void fun(char ch) void fun(int* pi)// 或者其他指针 // 用 int 调用同样会出错 void fun(double db) void fun(int* pi)// 或者其他指针
“嗯,很好。不过你还是漏了一种重要情况,”,Solmyr 补充道,“就是参数有缺省值的时候:”
// 调用时如果不给参数会出错 void fun(int i=10) void fun()
“天哪!”,zero 看起来快要崩溃了,“居然有这么多模棱两可的陷阱,这叫我怎样发布我的函数?在文档里写:以下 153 种调用方式将导致编译错误吗?”
“不要这么紧张,”,Solmyr 好整以暇的说到,“重载函数的模棱两可现象不是不能避免的,办法有两个:一是用模板来代替重载,尤其是象你的 SomeFunc 这样 int 型和 double 型处理算法相同的情况;二是如果要用重载的话,尽可能保证函数的参数个数不同。”
“可是如果处理算法不一样,函数需要的参数个数又相同,那该怎么办?”
“很简单,加入‘无用的参数’,象这样:”
void SomeFunc(float db, int) void SomeFunc(int i)
“第一个函数的第二个参数没有任何作用,所以你可以干脆不给它命名,只要声明一下有这个 int 型参数就可以了。文档里可以这样写:该参数是为今后升级预留的余地,调用时请传入 0 值。”
“ …… 你的文档里大概都是这一类的话吧 …… 啊!好痛!这回又是一本书!”,zero 被 Solmyr 突如其来的袭击击中,发出了悲惨的哀鸣。
“你得感谢先知 Scott Meyers,他的《 50 诫》轻而薄,我手上拿的若是一本教主 Bjarne Stroustrup 的《圣经》(注:指《The C++ Programing Language 3/e》一书,Bjarne Stroustrup 是 C++ 语言的设计者),你现在已经爬不起来了。”,Solmyr 再度披上了修养的伪装,不过言辞中仍然留着一点点杀气的痕迹 ……
“真是残暴的家伙 ……”,zero 小声嘟囔着。
“你说什么?”,杀气再度升高。
“不,不!我什么也没说!”,zero 连忙否认,试着转移话题,“啊!我懂了,要避免模棱两可的陷阱,一是用模板来替代重载,二是利用加入‘无用的参数’这一手段保证重载函数参数个数不同。这样就可以避开模棱两可的问题,是不是,Solmyr 老师?”。zero 很努力的装出天真无邪的样子。
“ …… 真是拙劣的演技 …… ”,Solmyr 心中暗想。“不完全,上述手段只能解决函数重载这一块而已,模棱两可问题涉及的情况要广泛的多,比如《 50 诫》中的例子:”
class B;// 前置声明 class A { public: A(const B&);// A 可以根据 B 构造出来 }; class B { public: operator A() const;// B 可以被转换为 A };
“这两个类本身没有什么问题,但若是有个函数需要 A 的对象作为参数,传过去的却是个 B 的对象时:”
void f(const A&) B b; f(b);// Error! ambiguous call
“注意到这里面的问题了吗?有两种一样好方法可以完成转换,一是用 A 的构造函数以 b 为参数构造一个新的 A 类对象,而是调用 B 的转换函数将 b 转换为一个 A 类对象。编译器再度无法区分哪个转换更好,只能报错了。后面还有一个多重继承的例子,你自己看吧”
“这 …… 这 ……”,zero 刚刚建立起来的对回避陷阱的自信再度崩塌。
“要回避一切模棱两可的问题是不可能的,”,Solmyr 站起身来,“关键是了解它为什么会发生,怎样的情况容易诱发它,然后小心的加以处理,C++ 中很多问题都是如此。这块《 50 诫》的石板就留给你了,好好研读吧。哈哈哈哈!”,Solmyr 一边笑着一边离开了 zero ,背影看起来像是一位飘然远去的高人 ……
“什么呀!根本就只是一个性格残暴的家伙而已,装模做样 …… 啪!”,zero 话音未落,一个文件夹划破空气飞来,正中 zero 的面门。
“呜 ~ 我什么也没说 ~”,zero 无力的辨白,然而换来的只是旁边的座位上再度传来低低的窃笑声而已。zero 明白,今天他的形象算是彻底的毁了 ……
Elminster
台下的座位已经坐满了,除了 Solmyr 的位子。zero 手足无措的望着那唯一的空位,开始第一百次的哀叹为什么自己会落到这样一个尴尬的位置。仅仅几分钟前,一切都还很正常,直到 …………
…………
主持人:“下一个议程,题为‘对象计数’的 C++ 编程技术讲座,主讲人是zero。”
zero: “什 …… 什么?!等一等,这个讲座不是应该由 Solmyr 主讲吗?!”
主持人:“嗯,原定是由 Solmyr 来讲,不过临时有要事出去了,离开之前他指定你顶替。他没有告诉你吗?”
zero: “他压根没有和我提过!我 …… 我什么准备也没做!这怎么行?别开玩笑了?!”
主持人:“你不用谦虚,Solmyr 临走前对我说过你完全能够胜任这个议题。啊对了,这里有一张他留给你的条子。”
zero 打开条子,但见上面写到:“《50 诫》(注:指《More Effective C++ 2/e》一书)看得怎么样了?如果你认真看过,就没问题。如果你敢拒绝或者出了岔子,嘿嘿 ……”
…………
“唉!”,zero 认命的叹了口气,“面对现实,硬着头皮上吧!”他决定就讲最简单的那部分,反正把这个场面搪塞过去就行了。他望着白板上“对象计数”四个大字,开口说到:“今天 …… 这个 …… 今天讨论的议题是‘对象计数’。所谓对象计数 …… 啊 …… 就是对计算某个类有多少个对象”。
开场白糟透了,zero 觉得还是尽快转入实际的东西比较好。
“对于这个问题 …… 最简单的做法是在需要计数的类中添加一个静态变量,保存当前的对象个数,并利用构造函数和析构函数增减它的值,象这样:”
class Wedget { public: Wedget(){ m_count++; }; ~Wedget(){ m_count--; }; int GetCout(){ return m_count; }; private: static int m_count; }; int Wedget::m_count = 0;
说着说着,zero 发现这件事似乎其实没有那么困难,反而觉得渐渐进入了状态,话也流利起来:
“上述做法很容易理解:一个类中的 static 类型的成员变量是被这个类的所有对象所共享的。当该类新增一个对象时,构造函数会保证计数值加一,销毁一个对象时,析构函数会保证计数值减一。这里唯一需要注意的只有一点:如果 Wedget 派生自一个基类,那么基类的析构函数一定得声明为虚函数。为什么呢?因为我们时常会用基类的指针操作派生类的对象,这是所谓“多态”的做法,面向对象程序设计的基本技术之一。也就是说下面这一类的代码会很常见:”
class Base …… class Wedget : public Base …… Base* pb = new Wedget; // 基类指针指向派生类对象 …… delete pb;
“但如果 Base 的析构函数没有声明为虚函数,那么当执行到 delete pb 这一句的时候,编译器只知道 pb 是一个 Base* 类型的指针,只会去调用 Base 类的析构函数,这样一来,明明销毁了一个 Wedget 类的对象,Wedget 类的析构函数却没有调用,计数值就会出现错误。所以必须将 Base 的析构函数声明为虚,告诉编译器去判断这个对象的实际类型,保证 Wedget 类的析构函数被调用。”
zero 顿了一顿,续道:
“顺便指出一下,这一点是 C++ 面向对象程序设计的一个普遍原则。”
zero 环视了一眼台下,发现所有人都听的很认真,有些人还露出了领悟的表情,这使得他信心大增,决定接着讲下去:
“某种意义上说,现在我们已经解决了‘对象计数’这个问题。但是事情还没完 —— 我们可能有许多类都需要对对象计数,如果我们对每个类都象上面这样手工的添这些代码进去,那么这个工作既枯燥乏味又容易出错,因此我们需要一种通用的机制。最简单的,当然是把上面的代码封装成一个类:”
class Counter { public: Counter(){ m_count++; }; ~Counter(){ m_count--; }; int GetCout(){ return m_count; }; private: static int m_count; }; int Counter::m_count = 0;
“然后在那些需要计数的类中添加一个 Counter 的成员,象这样:”
class Wedget { …… Counter m_MyCounter; };
“这样一来,新增一个 Wedget 对象也就新增一个 Counter 对象,销毁一个 Wedget 对象也就销毁一个 Counter 对象,看上去很完美。但是 ……”,zero 拖了个长音,“这样的解法是错误的!”说完,zero 在白板上夸张的打了一个大叉。
看到台下人们疑惑的表情,zero 对自己行为戏剧性的效果感到非常满意,他得意洋洋的解释:
“因为 static 成员是被该类所有的对象共享的,所以如果有另一个类,比如 Other 类也为了进行计数而包含了一个 m_MyCounter 成员的话,那么 Wedget 和 Other 类实际上是在共享一个计数值!请注意,Wedget 的 m_MyCounter 成员和 Other 的 m_MyCounter 成员都是 Counter 类的对象,它们共享同一个 m_count 静态变量。”
“OK,要绕开这个问题,必须用一点点小手段,那就是模板:”,zero 在白板上写出如下的代码:
template <class T> class Counter { public: Counter(){ m_count++; }; ~Counter(){ m_count--; }; int GetCout(){ return m_count; }; private: static int m_count; }; template <class T> int Counter<T>::m_count = 0; class Wedget { …… Counter<Wedget> m_MyCounter; }; class Other { …… Counter<Other> m_MyCounter; };
“看出其中的区别了吗?Counter
zero 一转身,惊讶的看到 Solmyr 不知什么时候已经出现在他座位上了,嘴边带着 —— 什么?没看错吧?zero 发现那不是 Solmyr 招牌式的坏笑,而是一种支持、赞许的微笑,zero 简直不能相信自己的眼睛。不过一转眼,Solmyr 的表情再度切换回了 zero 熟悉的模式 —— 快的让人以为刚才所看到的根本是幻觉 —— zero 心中一沉,知道事情有些不妙了,果然 ——
“我来提个问题。”,Solmyr 发话了,而且笑的很灿烂 …… (待续)
Elminster
“空泛的讨论让人厌烦。”,Solmyr 笑容可掬的说道,“不如我们设定一个简单的场景来看看你的计数器怎么使用吧。假设你是暴雪的程序员,要为星际争霸设计程序表示神族的单位,那么最简单的方案是 ——”,Solmyr 停了下来,望向 zero 。
zero 松了一口气 —— 这个问题还不算困难。他在脑中整理了一下思路:“神族的单位应该设计为一个基类,然后每种特定的兵种从这个类派生,每个单位就是这样一个类的对象。”想到这里,他飞快的在百板上写下:
class ProtossUnit { …… }; class Probe : public ProtossUnit { …… }; class Zealot : public ProtossUnit { …… }; class Dragoon : public ProtossUnit { …… };
Solmyr 点了点头,接着说到:“很好。接下来,我们都知道星际争霸里每个单位都是要占用人口的,也就是说我们得确切知道单位个数,很明显,这是一个对象计数的应用。那么我们该怎样利用你刚才实现的计数器呢?”
zero 顺手就在白板上写下:
class Probe : public ProtossUnit { …… Counter<Probe> m_MyCounter; }; class Zeolot : public ProtossUnit { …… Counter<Zeolot> m_MyCounter; }; class Dragoon : public ProtossUnit { …… Counter<Dragoon> m_MyCounter; };
不对!!
zero 心中划过警兆:这感觉太熟悉了!几乎每次惨遭 Solmyr 毒手之前,都有这种感觉!他几乎都可以感受到 Solmyr 正在寻找顺手的东西来砸他。一定有什么地方不对了!
回过头来看看自己写下的东西,zero 很快的发现了自己的错误:Counter
“既然是共享的,那么应该加到基类里。”,zero 急急忙忙的擦去了上面两行代码,写下:
class ProtossUnit { …… Counter<ProtossUnit> m_MyCounter; };
还 …… 还是不对!zero 立刻又发现了问题:不同的兵种可能占用的人口数并不相同,象 Probe 就只占用一个人口,而 Zealot 和 Dragoon 就要占用两个,这 …… 这 ……
zero 再度擦去了刚写下的代码,站在白板之前举棋不定。这时 Solmyr 的声音响了起来:“怎么了?有困难吗?”。此时 Solmyr 脸上的笑容显得特别可恶。
“不,我只是不清楚星际争霸中的人口是怎样定义的,这个游戏我从来没有玩过。”,zero 试图拖延一点时间。
“是吗?昨天我怎么还听到你在讨论‘星际争霸神族战术’?而且刚才你一下子就写出了三个神族兵种的名称,拼写准确。”,Solmyr 轻易的戳破了 zero 的谎言。
“……”,zero 不由得懊恼起来。“怎么办?得让它们共享一个计数器,而且每种兵种的计数值必须不一样 …… 对了!”zero 脑中灵光一闪,写下如下代码:
class Probe : public ProtossUnit { …… Counter<ProtossUnit> m_MyCounter; }; class Zeolot : public ProtossUnit { …… Counter<ProtossUnit> m_MyCounter; Counter<ProtossUnit> m_MyCounter; }; class Dragoon : public ProtossUnit { …… Counter<ProtossUnit> m_MyCounter; Counter<ProtossUnit> m_MyCounter; };
“Yeah!OK 了!”,zero 高兴的喊道,全然不顾台下带着笑意的目光 —— 这样的有趣场面已经成了公司里的著名娱乐之一。“共享一个计数器的关键是用哪个类别作为模板参数!不一定非得把本身作为模板参数,完全可以用各个兵种共同的基类!”
“那计数值不是 1 呢?”
“多放几个计数器就行了!”
“嗯,还算不错。”
zero 很高兴的看到 Solmyr 上前来在白板上打了一个勾,然而喜悦仅仅维持了一瞬间 —— Solmyr 顺手又在勾上打了一个点。
“为什么打个点?”,zero 不满的问。
“因为你的计数器设计不佳,想象一下 Carrier ,它占 8 个人口,你是不是要在 Carrier 类中写 8 个 Counter 成员?或者声明一个 Counter 的数组?这样的声明清晰吗?易读吗?”
“呃 ……”
“而且这样使用 Counter 成员变量,需要计数的对象在空间上会付出更大的代价,对于小对象,大小甚至可能翻一倍。”
“嗯 ……”
“更进一步的说,计数值为 n 的对象,需要构造 n 个 Counter 对象,运行性能也要受影响。”
“啊 …… ”
“现在你说说看,怎么改进你的计数器,同时不用改动原来的客户代码?”
“哦 …… ”
zero 陷入了沉思:改进后的计数器应该有指定计数值的能力,这个能力应该是 …… 应该是对应于一个计数器对象而非整个计数器类的,因为共享同一个计数器的类可能计数值不同,也就是说这里需要为计数器类的对象指定一个参数 …… 啊!原来这么简单!
“我知道了!答案就是构造函数!”,zero 飞快的把计数器类的定义改为(原来定义请参见上一期):
template <class T> class Counter { public: Counter(int step) // 改动部分 { m_step = step; m_count += m_step; }; ~Counter(){ m_count -= m_step; }; // 改动部分 int GetCout(){ return m_count; }; private: static int m_count; int m_step; // 新加部分 };
“嗯,不错,不过还有问题。”,Solmyr 一边点头一边说,“这样一来,以前编写的使用 Counter 类的客户代码就不能编译了 —— 它们会报告说构造的时候少了一个参数。”
“这好办。”,zero 很快发现了自己漏掉了什么。他把构造函数的定义改为:
Counter(int step = 1)
“这样一来,以前的客户代码会缺省的得到计数值 1 ,就像以前一样。”
“嗯,表现不错,不过 ……”
zero 心中一紧。
“算了,今天就这样吧。”
“Yeah!”
“把今天这些讨论整理成详细文档,下班以前交给我”
“啊!~~~~”
………………
就这样,再一次的,故事在 zero 的惨叫声中结束了。
Elminster
午餐时间。
zero 坐在餐桌前,机械的重复“夹菜 -> 咀嚼 -> 吞咽”的动作序列,脸上用无形的大字写着:我心不在焉。在他的对面坐着 Solmyr ,慢条斯理的吃着他那份午餐,维持着他一贯很有修养的形象 ——— 或者按照 zero 这些熟悉他本质的人的说法:假象。
“怎么了 zero ?胃口不好么?”,基本填饱肚子之后,Solmyr 觉得似乎应该关心一下他的学徒了。
“呃,没什么,只是 …… Solmyr ,C++ 为什么不支持垃圾收集呢?(注:垃圾收集是一种机制,保证动态分配了的内存块会自动释放,Java 等语言支持这一机制。)”
Solmyr 叹了口气,用一种平静的眼神盯着 zero :“是不是在 BBS 上和人吵 C++ 和 Java 哪个更好?而且吵输了?我早告诉过你,这种争论再无聊不过了。”
“呃 …… 是”,zero 不得不承认 ——— Solmyr 的眼神虽然一点也不锐利,但是却莫名其妙的让 zero 产生了微微的恐惧感。
“而且,谁告诉你 C++ 不支持垃圾收集的?”
“啊!Solmyr 你不是开玩笑吧?!”
“zero 你得转变一下观念。我问你,C++ 支不支持可以动态改变大小的数组?”
“这 …… 好象也没有吧?”
“那 vector 是什么东西?”
“呃 ……”
“支持一种特性,并不是说非得把这个特性加到语法里去,我们也可以选择用现有的语言机制实现一个库来支持这个特征。以垃圾收集为例,这里我们的任务是要保证每一个被动态分配的内存块都能够被释放,也就是说 ……”,Solmyr 不知从哪里找出了一张纸、一支笔,写到:
int* p = new int;// 1 delete p;// 2
“也就是说,对于每一个 1 ,我们要保证有一个 2 被调用,1 和 2 必须成对出现。我来问你,C++ 中有什么东西是由语言本身保证一定成对出现的?”
“……”,zero 露出了努力搜索记忆的表情,不过很明显一无所获。
“提示一下,和类的创建有关。”
“哦!构造函数与析构函数!”
“正确。可惜普通指针没有构造函数与析构函数,所以我们必须要写一个类来加一层包装,最简单的就象这样:”
class my_intptr { public: int* m_p; my_intptr(int* p){ m_p = p; } ~my_intptr(){ delete m_p; } }; ………… my_intptr pi(new int); *(pi.m_p) = 10; …………
“这里我们可以放心的使用 my_intptr ,不用担心内存泄漏的问题:一旦 pi 这个变量被销毁,我们知道 pi.p 指向的内存块一定会被释放。不过如果每次使用 my_intptr 都得去访问它的成员未免太麻烦了。为此,可以给这个类加上重载的 * 运算符:”
class my_intptr { private: int* m_p; public: my_intptr(int* p){ m_p = p; } ~my_intptr(){ delete m_p; } int& operator*(){ return *m_p; } }; ………… my_intptr pi; *pi = 10; int a = *pi; …………
“现在是不是看起来 my_intptr 就像是一个真正的指针了?正因为如此,这种技术被称为智能指针。现在我问你,这个类还缺少哪些东西?”
zero 皱着眉头,眼睛一眨一眨,看上去就像一台慢速电脑正在辛苦的往它的硬盘上拷贝文件。良久,zero 抬起头来,不太确定的说:“是不是还缺少一个拷贝构造函数和一个赋值运算符?”
“说说为什么。”,Solmyr 显然不打算就这样放过 zero。
“因为 …… 我记得没错的话 …… 《50 诫 》(注:指《Effective C++ 2/e》一书)中提到过,如果你的类里面有指针指向动态分配的内存,那么一定要为它写一个拷贝构造函数和一个赋值运算符 …… 因为 …… 否则的话,一旦你做了赋值,会导致两个对象的指针指向同一块内存。对了!如果是上面的类,这样一来会导致同一个指针被 delete 两次!”
“正确。那么我们应该怎样来实现呢?”
“这简单,我们用 memcpy 把目标指针指向的内存中的内容拷贝过来。”
“如果我们的智能指针指向一个类的对象怎么办?注意,类的对象中可能有指针,不能用 memcpy。”
“那 …… 我们用拷贝构造的办法。”
“如果我们的智能指针指向的对象不能拷贝构造怎么办?它可能有一个私有的拷贝构造函数。”
“那 ……”,zero 顿了一顿,决定老实承认,“我不知道。”
“问题在哪你知道么?在于你没有把智能指针看作指针。想象一下,如果我们对一个指针做赋值,它的含义是什么?”
“呃,我明白了,在这种情况下,应该想办法让两个智能指针指向同一个对象 …… 可是 Solmyr ,这样以来岂不是仍然要对同一个对象删除两遍?”
“是的,我们得想办法解决这个问题,办法不只一种。比较好的一种是为每个指针维护一个引用计数值,每次赋值或者拷贝构造,就让计数值加一,这意味着指向这个内存块的智能指针又多了一个;而每有一个智能指针被销毁,就让计数值减一,这意味着指向这个内存块的智能指针少了一个;一旦计数值为 0 ,就释放内存块。象这样:”
class my_intptr { private: int* m_p; int* m_count; public: my_intptr(int* p) { m_p = p; m_count = new int;// 初始化计数值为 1 *m_count = 1; } my_intptr(const my_intptr& rhs)// 拷贝构造函数 { m_p = rhs.m_p;// 指向同一块内存 m_count = rhs.m_count;// 使用同一个计数值 (*m_count)++;// 计数值加 1 } ~my_intptr() { (*m_count)--;// 计数值减 1 if( *m_count == 0 )// 已经没有别的指针指向该内存块了 { delete m_p; delete m_count; } } my_intptr& operator=(const my_intptr& rhs) { if( m_p == rhs.m_p )// 首先判断是否本来就指向同一内存块 return *this;// 是则直接返回 (*m_count)--;// 计数值减 1 ,因为该指针不再指向原来内存块了 if( *m_count == 0 )// 已经没有别的指针指向原来内存块了 { delete m_p; delete m_count; } m_p = rhs.m_p;// 指向同一块内存 m_count = rhs.m_count;// 使用同一个计数值 (*m_count)++;// 计数值加 1 } ………… };
“其他部分没有什么太大变化,我不费事了。现在想象一下我们怎样使用这种智能指针?”,Solmyr 放下了笔,再次拿起了筷子,有些惋惜的发现他爱吃的肉丸子已经冷了。
zero 想象着,有些迟疑。“我们 …… 可以用 new int 表达式作为构造函数的参数来构造一个智能指针,然后 …… 然后我们可以任意的赋值,”,他开始抓住了思路,越说越快,“任意的用已经存在的智能指针来构造新的智能指针,智能指针的赋值运算符、拷贝构造函数和析构会保证计数值始终等于指向该内存块的智能指针数。”zero 似乎明白了他看到了怎样的功能,开始激动起来:“然后一旦计数值为 0 被分配的内存块就会释放!也就是说 …… 有指针指向内存块,它就不释放,一旦没有,它就自动释放!太棒了!我们只要一开始正确的初始化智能指针,就可以象普通指针那样使用它,而且完全不用担心内存释放的问题!太棒了!”zero 激动的大叫:“这就是垃圾收集!Solmyr !我们在饭桌上实现了一个垃圾收集器!”
Solmyr 很明显没有分享 zero 的激动:“我在吃饭,你能不能不要大叫‘饭桌上实现了一个垃圾收集器’这种倒胃口的话?”顿了一顿,Solmyr 带着他招牌式的坏笑,以一种可恶的口吻说道:“而且请注意一下自己的形象。”
“嗯?”,zero 回过神来,发现自己不知什么时候站了起来,而整个餐厅里的人都在看着他嘿嘿偷笑,这让他感觉自己像个傻瓜。 zero 红着脸坐下,压低了声音问 Solmyr :“不过 Solmyr ,这确实是一个的垃圾收集机制啊,只要我们把这个类改成 …… 嗯 …… 改成模板类,象这样:”zero 抓过了纸笔,写到:
template <typename T> class my_ptr { private: T* m_p; int* m_count; ………… };
“它不就能支持任意类型的指针了吗?我们就可以把它用在任何地方。”
Solmyr 摇了摇头:“不,你把问题想的太简单了。对于简单的类型,这个类确实可以处理的很好,但实际情况是很复杂的。考虑一个典型情况:类 Derived 是类 Base 的派生类,我们希望这样赋值:”
Base* pb; Derived pd; ………… pb = pd;
“你倒说说看,这种情况,怎样改用上面这个智能指针来处理?”
“……”,zero 沉默了。
“要实现一个完整的垃圾收集机制并不容易,因为有许多细节要考虑。”,Solmyr 开始总结了,“不过,基本思路就是上面说的这些。值得庆幸的是,目前已经有了一个相当成熟的‘引用计数’智能指针,boost::shared_ptr。大多数情况下,我们都可以使用它。另外,除了智能指针之外,还有一些技术也能够帮助我们避开释放内存的问题,比如内存池。但是,关键在于 ——— ”
Solmyr 再度用那种平静的眼神盯着 zero :
“身为 C/C++ 程序员,必须有创造力。那种躺在语言机制上不思进取的人,那种必须要靠语法强制才知道怎样编程的人,那种没有别人告诉他该干什么就无所适从的人,不适合这门语言。”
Elminster
“呼 ~~~~ 啪!”
一个文件夹划出一道优美的弧线,越过四张桌子,两堵隔墙,一条走道,不偏不倚的穿过了正在交谈的路人甲和路人乙,精准的命中了目标。放眼公司上下,拥有这般投掷手法的,只有 Solmyr ,而他的目标,自然是 zero 了。
“哎哟!”,zero 摸了摸被击中的后脑勺,一半不甘一半认命的叹了一口气:不用问,他一定又有什么把柄被 Solmyr 抓住了。
“这次我又犯了什么错误了?”,zero 匆匆中断了与方圆五十米内唯一的女程序员 pisces 之间愉快的闲聊,来到 Solmyr 身边看看究竟哪里出了不妥。
“你刚刚提交的代码会导致线程死锁”,Solmyr 指着 zero 提交的一个函数:
void some_func() { pthread_mutex_lock(&mtx); …… …… pthread_mutex_unlock(&mtx); }
“会吗?我明明在函数末尾释放了互斥变量的呀?”
Solmyr 看了看 zero ,那表情分明在说:朽木不可雕也。他顺手标出了函数中间的两行代码:
void some_func() { pthread_mutex_lock(&mtx); …… if( status == E_FAIL ) return; …… pthread_mutex_unlock(&mtx); }
“Oops!”,zero 拍了一下脑门,“我知道了我知道了,我这就改。”
“你知道了?说说看你犯了什么错误?”
“我忘了在中间的函数返回点解锁。”
“那你准备怎么解决这个问题”,很明显,Solmyr 不打算就此轻轻放过 zero。
“嗯 …… 很简单啊,在这里加上一行代码,象这样:”
if( status == E_FAIL ) { pthread_mutex_unlock(mtx); return; }
Solmyr 摇摇头:“你这是头痛医头,脚痛医脚。如果你这个函数里不只一个锁,不只一个返回点,你打算怎么做?在每个返回点解开每个锁么?”
“嗯 …… 你是指我应该遵循一个函数只有一个返回点的原则?”,zero 挠挠头,有些不太确定。
“我不是指这个。有些情况下,硬要让函数只有一个返回点会导致巨大的 if/else 结构,降低代码的可读性。而且,即使你的函数只有一个返回点,你还是有可能遇到这个问题。考虑这样的函数:”,Solmyr 飞快的键入:
void some_func() { pthread_mutex_lock(&mtx); …… // 中间没有其他返回点 …… foo(); // 由其他程序员实现的函数 …… pthread_mutex_unlock(&mtx); }
“看起来一点问题也没有,可是如果 foo 这个函数丢出异常的话,会出现什么情况?”
“嗯 …… 如果我们函数里没有捕获这个异常的话 …… 它会导致 some_func 函数在调用 foo 的这一点中断 …… 哎呀 ……”,zero 发现了问题所在。“那么只能在每个可能抛出异常的函数调用点用 try 捕获所有异常,然后 ……”,zero 越说越小声,“ …… 然后在 catch 里面解锁,再重新抛出 ……” zero 停了下来,烦恼的挠着头,发现他连自己都说服不了:这样的解法实在是太繁琐、太容易引入错误了。
“嗯?”
“好吧,我承认我不知道该怎么办了,Solmyr ,这种情况应该怎么处理呢?”
“回忆一下,前两天我们在饭桌上讨论过什么?”(参见“Solmyr 的小品文系列”的前一期,“垃圾收集”)
“你是说垃圾收集吗?哎 …… 可是 …… 那是处理内存泄漏的呀?和这个问题有什么关系?”
“我不是指具体的解法,”,Solmyr 摇摇头,“关键是上次讨论中引入的具有普遍性的原则,也就是 ……” Solmyr 停了下来,转头看着 zero 。
“…… ……”
“唉 ……”,Solmyr 用别人模仿不来的无奈表情 —— 按照他自己的说法,这是多年培训工作的积累 —— 叹了口气:“我说 zero,你还很年轻,不会这么早就记忆力衰退了吧?”
…… 真是可恶的家伙,zero 心中恨恨的想。
Solmyr 的声音再度在 zero 接近崩溃边缘的时候响了起来:“如果你希望保证某些事情成对出现,请使用 ……”
“构造函数与析构函数!”,zero 生怕错过了显示自己并非“记忆力衰退”的机会。
“不用喊那么大声。”,Solmyr 皱了皱眉,“你把前排观众都吓坏了。”
“?!!!”,zero 迅速转身,发现附近不知什么时候围满了公司的同事,每个人都“正常”在做自己的事情,只是动作稍显忙乱而已 ……
解决了四周的“观众”之后,zero 回到了显示器前,信心满满:“我知道了 Solmyr ,这里我们可以用和上次处理 分配/释放 内存非常类似的手段来处理 加锁/解锁,只要写一个非常简单的类就行了,象这样:”,zero 一边说,一边键入:
class auto_lock { public: auto_lock(pthread_mutex_t mtx) : m_mtx(mtx) { pthread_mutex_lock(&m_mtx); // 构造时加锁 } ~auto_lock() { pthread_mutex_unlock(&m_mtx); // 析构时解锁 } private: pthread_mutex_t& m_mtx; } void some_func() { auto_lock(mtx); …… // return 、foo ,随便什么东西都行 …… // 结束的时候同样不用解锁 }
“这样一来,我之前遇到的问题就全解决了,我可以自由的实现我的函数,不论什么时候返回或者遇到异常,我都可以肯定 mtx 将会被解锁,不用担心线程死锁的问题。”
“嗯,不错。” Solmyr 赞许的点了点头,开始总结:“实际上这是一个非常常用的手段,除了我们讨论过的两种情况而外,还可以应用在很多场合。比如网络访问中的建立连接和断开连接,数据库访问中的登录与退出登录,还可以方便的用它来实现测量一个函数平均运行耗时的测试工具,等等等等。不过万变不离其宗,在这一切应用的背后是一个统一的原则 ……”
Solmyr 顿了一顿,zero 心领神会的接了上去:
“如果你希望保证某些事情成对出现,请使用构造函数与析构函数。”
Elminster
大雨。
乌云象铅块一样低低的压了下来,豆大的雨滴打的玻璃窗啪啪作响,难得一见的异常天气正在竭力表现它令人讨厌的一面。不过这一切似乎并没有影响到 Solmyr,他仍然以他习惯的舒适姿势半躺在宽大的椅子里,手里还托着一杯热腾腾的果汁,在他背后,zero 在键盘上敲打着什么。
“唉,Solmyr ,标准库中的 stack 怎么会是这个样子?设计糟透了。”zero 停止了工作,转过身来面对 Solmyr ,看起来有些困惑。
“胡乱批评被纳入神圣标准的成员是会遭天遣的。”Solmyr 低着头,以一种算命先生似的语调答道。
不知道上天是否打算加强 Solmyr 的说服力,恰在此时天空划过一道闪电,蓝白色的电光挣扎着努力向地面扑来,紧接着就是“喀喇”一声巨响 ——— 这个雷很近。
一秒钟前还在想“这未免也太扯了”的 zero 表情一下子变得很古怪,良久才恢复正常。他标出了两行代码接着说到:“好、好吧,Solmyr,那请你解释一下为什么 stack 的界面是这个样子。”
std::stack<int> si; …… int i = si.top(); si.pop();
“只要让 pop() 返回栈顶元素就可以把上面两行合成一行,而且更加直观,为什么要搞成现在这样?”
目睹了 zero 表情变化的 Solmyr 强忍住放声大笑的冲动 ——— 老天知道他忍的有多辛苦 ——— 缓缓的把杯子放到桌上,转过身来开始讲解这个问题:
“原因在于异常。”
“异常?”
“对,很多代码在没有异常的时候工作的挺好,但是一旦出现异常就变得不可收拾,就像一间茅草屋,平时看起来没什么问题,一遇到今天这种天气 …… ”,Solmyr 指了指窗外,“ …… 立刻就会垮掉。考虑一下如果 pop() 返回栈顶元素需要怎样实现,假设栈内部用数组实现,且不考虑栈是否为空的问题。”
“很简单啊。”,zero 打开了编辑器,写下:
template <typename T> T stack<T>::pop() { ... ... return data[top--]; // 假设数据存储于数组 data 中,top 代表栈顶位置 }
Solmyr 摇摇头:“这就是茅草屋。要知道 stack 是个模板类,它存放的元素 T 可能是用户定义的类。我来问你,如果类型 T 的拷贝构造函数抛出异常,会出现什么情况?”
“嗯 …… 按值返回,返回值是个临时对象,该临时对象以 data[top] 拷贝构造 …… 嗯,这样一来函数返回时可能抛出异常,客户此时无法取得该元素。”
“还有呢?”
“还有?”
“提示,你的 top 怎么了?”
“ …… 哎呀!糟了!top 此时已经减一,栈顶元素就此丢失了!这样的话 …… 必须实现一个函数允许客户修改 ……”,zero 说不下去了。他想了一会,摇摇头承认失败:“不行,这里拷贝构造发生在函数返回之后,无论如何无法避免这种情况。只能在文档里写明:要求 T 的拷贝构造函数不抛出异常。” zero 停了一停,小心翼翼的问 Solmyr :“这个不算过分的要求吧?”
Solmyr 的回答异常简短:“new”
“哦对,new 在分配内存失败时会抛出 std::bad_alloc …… 算我没说。Solmyr ,我明白了,为了处理异常的情况,调整栈顶位置必须在所有数据拷贝完成之后,所以按值返回是不可接受的。”
“正确。所以对于一个设计目标是最大限度可复用性的标准库成员而言,这是不可接受的。” Solmyr 顿了顿,继续说到:“而且异常带来的影响远不止此。我刚才说‘假设栈内部用数组实现’,但如果你充分考虑抛出异常的各种可能性,你就会发现用数组实现是糟糕的主意。”
“ …… …… …… …… …… 这是为什么?在没有传值返回的情况下,我们总可以捕捉到发生的异常并加以处理啊?”,zero 谨慎的发问。
Solmyr 赞许的看着 zero 。“发问之前先自行思考,习惯不错。”,Solmyr 心想,但是脸上一点也没表现出来:“没错,但捕捉到异常不代表你总能正确的处理它。考虑一下 stack 的赋值运算符,如果我们用数组来实现,那么在拷贝数据的时候肯定会有类似这样的一个循环:”
// 各变量的意义与上面相同 template <typename T> stack<T>& stack<T>::operator=(const stack<T>& rhs) { ... ... for(int i=0; i<rhs.top; i++) data[i] = rhs.data[i]; ... ... }
“现在考虑类型 T 的赋值运算符可能抛出异常,该怎样修改上面的代码。” Solmyr 停了下来,再度捧起了杯子。
“用 try 把 …… 哦 …… …… …… …… …… ……”,zero 似乎发现了问题所在,沉默良久,才接着说到:“这个循环可能在运行到一半的时候抛出异常,这样会导致一部分数据已经成功赋值,另一部分却还是老的。除非我们用 catch(...) 捕捉所有异常,忽略之并继续赋值。”
“但是这样 ……”,Solmyr 有意识的引导 zero 继续深入思考。
“…… 但是这样,赋值运算符抛出的异常就被我们‘吃掉了’,异常总是代表着某些不该发生的事情发生了,所以应该让客户接收到这个异常才对。” zero 皱着眉头,一字一顿,显得相当辛苦。
“正确。stack 作为一个通用的标准库成员,在面对异常时必须做到两点。一、异常安全,也就是说异常不会导致它本身处于一种错误的状态或是导致数据丢失或是造成资源泄漏;二、异常透明,也就是说客户代码 ——— 这里指它存放的类型 T 的实现 ——— 抛出的任何异常,不应该被‘吃掉’或者被改变,应该透明的传递给客户。一望即知,上面的代码无可能同时做到这两点。”
“是这样,我懂了,这大概就是标准库中的 stack 不用数组实现的主要原因了吧”,zero 露出了很有把握的神情。
“当然不是!有点常识好不好,用数组实现的话 stack 的大小固定,这怎么能够接受呢?!”
又一次的,Solmyr 目睹了 zero 表情发生难以言喻的剧烈变化。这次他没能忍住放声大笑的冲动,连杯子里的果汁也洒了出来,一时间,笑声充满了整个办公室 ——— 不仅仅是他的,还包括了(众位看官应该猜的到吧?)围观同事们的笑声。
驱散了围观者之后,zero 面带愠色的坐下:“有那么好笑吗?”
“抱歉抱歉,我 …… 哈哈哈 …… 我 …… 哈哈 …… 我只是一时忍不住 …… 哈哈哈哈 …… ”,Solmyr 好容易平息了大笑,坐直了身子,放下了果汁,正色道:“关键在于上面引入的应该遵循的两条原则,也就是异常安全,和异常透明。现在你考虑一下如果 stack 内部的数据以指针存放,怎样在赋值运算符中保证上述两点?”
“ …… 嗯 …… 还是会有上面那样一个循环 …… 呃 …… ”,zero 面有难色。
“提示,不一定非得直接拷贝到 stack 保存数据的内存里。”
“ …… 嗯 …… 不直接拷贝,那么就是 …… 就是拷贝到 …… 啊!我明白了!”,zero 抓住了其中的关键,飞快的写下:
// pdata 代表指向存放数据内存的指针,top 代表栈顶元素的偏移量 template <typename T> stack<T>& stack<T>::operator=(const stack<T>& rhs) { ... ... T* ptemp = new T[rhs.top]; try { for(int i=0; i<rhs.top; i++) *(ptemp+i) = *(rhs.pdata+i); } catch(...) // 捕捉可能出现的异常 { delete[] ptemp; throw; // 重新抛出 } delete[] pdata; // 释放当前的内存 pdata = ptemp; // 让 pdata 指向赋值成功的内存块 ... ... }
“只要这样”,zero 边输入边说,“只要先把数据拷贝到一个临时分配的缓冲区,在此过程中处理异常,然后让 pdata 指向成功分配的内存就行了。这里的关键是让拷贝动作成为可以 …… 呃 …… 可以安全的取消的,剩下的赋值动作就是简单的指针赋值,肯定不会抛出异常了。”
“非常好。值得指出的是,这是一种相当常见的手段,有个名字叫做 copy & swap ,它不仅仅可以用来应付异常,也可以有效的实现一些其他特征。OK,这个问题大概就是这样了。”
问题似乎可以告一段落了,Solmyr 开始打算就此结束这个话题。可 zero 疑惑的表情阻止了他。
“还有什么问题吗?zero ?”
“啊 …… 没什么,我只是在想,异常导致了这么多麻烦,这一次,还有上一次的线程死锁问题(参见“小品文系列”的前一篇,“成对出现”)都是因为异常的存在才会变得如此复杂的,那为什么 C++ 还要支持它呢?有错误完全可以在返回值里报告嘛。”
“嗯,这确实是个常见的疑惑,不过答案也很简单,异常的存在有它自己的价值。一、使用异常报告错误可以避免污染函数界面;二、如果你希望报告比较丰富的错误信息,使用一个异常对象比简单的返回值要有效的多,而且避免了返回复杂对象造成的开销;三、也是我认为比较重要的,有些错误不合适用返回值来报告。举个例子,动态内存分配。我问你,C 语言中怎样报告动态内存分配错误?”,Solmyr 转过头来看着 zero 。
“malloc 函数返回一个 NULL 值代表动态内存分配错误。”
“但是你见过多少 C 程序员在每次使用 malloc 之后都检查返回值?”
“ …… ”
“没有是吗?这很正常,每次使用 malloc 之后检查返回值是件令人痛苦的事情,所以即使有 Steve Maguire(注:《Writing Clean Code》一书的作者)这样的老程序员谆谆教导、耳提面命,还是有数以万计的 C 程序中存在这样的代码:”,Solmyr 顺手键入:
/* 传统 C 程序 */ int* p = malloc( sizeof(int) ); *p = 10;
“一旦 malloc 失败返回 NULL,这个程序就会崩溃。然而如果是 C++ 程序,使用 new 的话 …… ”,Solmyr 键入了对应的代码:
// C++ 程序 int* p = new int; *p = 10;
“就不存在这样的问题。我问你,这是为什么?”
zero 很快找到了答案:“因为如果 new 失败,它会抛出 std::bad_alloc 异常,于是函数在此中断、退出,下面这一行也就不会被调用了。”
“正确。而且你不必在每一处处理这个异常,你只要保证你的程序对异常透明,就可以在 main 函数中写下 try ... catch 对,捕获所有未捕获的异常。比如你可以在 main 函数中捕捉 std::bad_alloc,在输出‘内存不足’错误信息,然后保存所有未保存的数据,完成所有的清理工作,最后结束程序。一言以蔽之,体面的退出。”
zero 点着头,喃喃的重复着:“对,体面的退出。”
见 zero 领会了他的意思,Solmyr 继续开始下一个议题:“异常的存在还有最后一个重要价值 ——— 也是当初设计它的初衷之一 ——— 提供一个通用的手段让构造函数可以方便的报告错误:因为构造函数没有返回值。”
“还有析构函数也是。”没等 Solmyr 说完,zero 就加上了这一句。
Solmyr 对着自作聪明的 zero 摇了摇头:“不要想当然,关于异常有一个非常重要的原则:永远不要让你的析构函数抛出异常。知道为什么吗?”
“ …… 不知道。” zero 这次决定老实承认。
“因为抛出异常的析构函数会导致最简单的程序无法正确运行,比如下面两句:”这次出现在屏幕上的,是看来似乎毫无瑕疵的两行代码:
evil p = new evil[10]; delete[] p;
“看上去一点问题也没有是么?仔细分析一下 delete[] p 这一句,它会调用 10 次 evil 类的析构函数,假设其中第 5 次 evil 类的析构函数抛出异常,会出现什么情况?”
zero 陷入了沉思,视线盯着屏幕一动不动,神情看起来就象是一段执行复杂运算的程序,而且是没有输出的那种。不过没多久,zero 就换了一种表情,这种表情通常被形容为胸有成竹:“我知道了 Solmyr ,在这种情况下,delete[] 面临两难选择。选择一是不捕捉这个异常,让它传播到调用者那里,但这样一来 delete[] 就被中断了,后面的 5 个 evil 对象占用的内存就会无法释放,导致资源泄漏;选择二是捕捉这个异常以防止资源泄漏,但这样一来这个异常就被 delete[] 吃掉了,违反了‘对异常透明’的原则。所以无论怎么做,都没法妥善的处理析构函数抛出异常的情况。”
Solmyr 赞许的点头:“非常好。接下来,你的任务是 ……”
“我知道我知道,把这些讨论整理成文档是吧?我这就动手。”
zero 转过身去,开始埋头于他的文档。而 Solmyr 则再度恢复了半躺半坐的舒适姿势,捧起了他的果汁,并且略略有些意外的发现 ———
天气放晴了。
这篇小品文大量参考了以下两篇文章:
"Exception Handling: A False Sense of Security" by Tom Cargill
Exception-Safe Generic Containers by Herb Sutter
Elminster
“zero 帮帮忙吧 ~~ ”
“灿烂”的笑脸,充满诚意的眼神,再加上点头哈腰的姿势,这三者构成了一尊名为“有求于人”的塑像。
在 QQ 上聊的正欢的 zero 抬起头,看着塑像的作者和材料 ——— pisces ,方圆五十米内唯一的女性程序员 ——— 问道:“什么事?”
“我这里有一段 C++ 程序调不通。”
“这类问题你应该去问 Solmyr。”
“哎呀,别开玩笑了,我哪敢去问他呀!总说我笨!上次问他一个小问题,结果又被训的狗血喷头,哼!”,pisces 显得忿忿不平,“还是你来帮帮我吧,我知道你是部门里有数的高手,肯定搞的定的。帮帮忙吧 ~~”
zero 明显的被打动了,于是,在 pisces 的努力下,zero 坐到了 pisces 的计算机前。
“好吧,什么问题?”
“是这样的啦,这里有一组 C 风格的 API ,负责管理设备上的字符通信链接。它们是好些年前设计的”,说着,pisces 调出了一些代码:
// old C style API typedef int conn_handle; typedef struct { /* ... 打开链接所需的参数和属性 ... */ }conn_attr; conn_handle open_conn(conn_attr* p_attr, char* buf, unsigned int buf_size); void close_conn(conn_handle h); char read_conn(conn_handle h); void write_conn(conn_handle h, char c); ...
“枝节的东西不算,主干大概就是这样,一对函数负责打开和关闭,一对函数负责读写。创建链接时候的那个 buf 参数指向一个缓冲区,这个要你自己分配并把长度传进去,和链接一一对应,read_conn/write_conn 会用它做缓冲。我的任务就是写个类把这些 API 包装起来。”,说着 pisces 又调出了另外一段代码:
// pisces' connection class class connection { private: conn_attr m_attr; bool m_opened; int m_bufsize; char* m_buf; conn_handle m_h; ... public: connection(const conn_attr& attr, int bufsize) { m_attr = attr; m_opened = false; m_bufsize = bufsize; m_buf = new char[m_bufsize]; } ~connection() { delete m_buf; } void open() { m_h = open_conn(&m_attr, m_buf, m_bufsize); m_opened = true; } void close() { close_conn(m_h); m_opened = false; } char read() { assert(m_opened); return read_conn(m_h); } void write(char c) { assert(m_opened); write_conn(m_h, c); } ... };
“应该是很简单的,可是不知道怎么回事,用了 connection 类的程序总是时不时的崩溃,说是非法的内存操作。”,pisces 显得很苦恼。
zero 一眼就看出了毛病 ——— 这使他小小的自鸣得意了一下 ——— 但是表面上不动声色,等到他看过 pisces 提供的“总是引发崩溃”的代码段之后,他才开口说到:
“这是一个常见的错误 pisces”,zero 尽量使自己的口吻和语气听起来象一个权威,“关于 C++,有一条重要的指导原则:析构函数、拷贝构造函数和赋值运算符三者几乎总是一起出现。也就是说,如果你为一个类写了析构函数,那么往往你不得不再提供一个拷贝构造函数和一个赋值运算符,违反它往往意味着错误。你看这里:”
说着,zero 在屏幕上标出了两行代码:
void some_func() { conn_attr attr; ... connection c1(512, attr); connection tmp = c1; ... }
“这里对象 tmp 是从 c1 拷贝构造而来的,而你没有定义拷贝构造函数,这使得编译器在这里自动进行按位拷贝,而这使得 tmp 和 c1 的所有成员都相等,包括 m_buf 成员。这样在函数返回时,c1 析构的时候 delete 了一遍 m_buf,在 tmp 析构的时候又 delete 了一遍 ……”
“哦!我明白了!” pisces 打断了 zero ,“所以就出现一个非法内存操作,对吧?哎呀,这一条以前在学校里写 string 类的时候遇到过,我怎么会忘了呢?”
“对,你只要写一个拷贝构造函数和一个赋值运算符处理一下 m_buf 指针就可以解决这个问题了。这你自己搞的定吧?”
“我可以的,多谢了 zero !”
zero 心满意足的回到了自己的座位上,开始继续和“你不懂我纤细的心”在 QQ 上探讨“爱情的意义”。可是好景不长,没过多久,本文开头所描述的景象再一次的出现了。
“zero 帮帮忙吧 ~~ ”
zero 在心中叹了口气,抬头问道:“又是什么问题,pisces?”
“呃,还是那个类。我照你说的给 conn 添加了拷贝构造函数,非法内存操作确实少多了,可还是有,还有好像链接传输数据也有点问题 ———— 你还是过来帮我看看吧 ~~”
zero 心不甘情不愿的再次来到了 pisces 的计算机前,翻出 pisces 写的拷贝构造函数检查起来:
connection(const connection& other) { m_attr = other.m_attr; m_bufsize = other.m_bufsize; m_buf = new char[m_bufsize]; memcpy(m_buf, other.m_buf, m_bufsize); m_opened = other.m_opened; m_h = other.m_h; }
zero 的眉头皱了起来,这个拷贝构造函数似乎应该可以解决问题,显然现在两个 m_buf 各自指向合法的内存,不再存在两次释放的问题。那么问题出在哪儿呢?zero 陷入了沉思。不过仅仅多花了不到 1 分钟,zero 就明白了过来。
“哦!我明白了!见鬼,我怎么会没注意到这个。pisces ,问题还是出在 m_buf 上面,因为链接和缓冲区指针是一一对应的,所以拷贝构造函数里新分配的缓冲区根本不起作用。”
pisces 眨了眨眼,表情略显呆滞。
“给你举个例子吧。”zero 飞快的键入一段测试代码:
connection* pc = NULL; { conn_attr attr; connection c1(512, attr); c1.open(); pc = new connection(c1); } pc->write('A');
“c1 的构造函数里调用 new 为它的 m_buf 成员分配内存,紧接着在 open 函数里调用 open_conn 打开了一个链接,注意这里我们传入 open_conn 的参数是 c1.m_buf ,所以这个链接对应的缓冲区指针是 c1.m_buf 。然后我们执行 pc = new connection(c1),新对象从 c1 拷贝构造,所以 pc->m_h 和 c1.m_h 相等,也就是说这两个对象保存的 m_h 标识着同一个链接,对应的缓冲区指针都是 c1.m_buf ———— ”
zero 象 Solmyr 常做的那样停了下来,但却失望的看到 pisces 毫无反应,只好接着往下说:
“所以接下来的 pc->write 在调用 write_conn 时候,这个 API 并不知道这是通过另外一个对象在调用它,它仍然试图使用 c1.m_buf 作为缓冲区,但这个时候 c1 已经结束了它的生命周期,c1.m_buf 已经被释放了,所以,这是一个非法的内存访问。”
pisces 舔了舔嘴唇:“ …… 那 …… 那么现在怎么办?”
zero 翻了个白眼 ——— 很明显 pisces 根本没明白是怎么一回事 ——— 开始考虑怎样应付眼前这个问题。
“嗯,看样子,这里必须考虑多个对象共享一个指针的问题,嗯,为了保证这块内存被释放 …… 恐怕 …… 恐怕得用上引用计数技术(请参见“小品文系列之五:垃圾收集”)才搞得定,要不要用 boost::shared_ptr 呢?”,zero 一边想,一边自言自语。突然间 ———
“逻辑的混乱导致实现上的复杂,zero,这个 connection 类千疮百孔啊。”,Solmyr 的声音毫无预兆的在背后响起。
zero 在 0.01 秒内控制住了拔腿飞奔的冲动,以尽可能放松的姿态缓缓的转过身来。在他的面前是披着一贯优雅伪装的 Solmyr,一手端着果汁,一手牢牢的拽着仍在拼命挣扎试图逃走的 pisces 。
“啊 Solmyr ,我正想找你呢,这个问题稍许有点棘手。”
“是吗?那你的腿为什么在抖?”
“嗯?没有,有点冷而已 …… 啊 Solmyr ,你刚刚说什么来着?”
“逻辑的混乱导致实现上的复杂,zero,这个 connection 类千疮百孔。”Solmyr 把 pisces 按在旁边的座位上,接着说到:“你刚才发现的问题只是其中之一而已。看一下这个:”
void some_func() { conn_attr attr; ... connection c1(512, attr); c1.open(); ... connection tmp = c1; c1.close(); tmp.write('a'); ... }
“这会导致什么?”
“ …… 试图写入一个已经关闭了链接。”
“还需要我给出多次打开一个链接,多次关闭一个链接,以及各种链接处于打开状态但读写却会引发断言错误的例子吗?”
“ …… 不用了。”
“那你打算怎样修复这些问题?要不要在每个对象里保存一个由它拷贝构造而来的对象列表?或者你打算在文档里写‘以下 371 种方式使用该类会导致无法预知的错误’?”
“ …… ”
Solmyr 重重的叹了口气:“你被 pisces 误导了,zero,因为你只想着怎么帮 pisces 解决问题,如果一开始就让你来设计这个类,情况一定不会这么糟糕。”说着,Solmyr 狠狠的瞪了 pisces 一眼。“不要忘了,C++ 类不是简单的把一堆成员变量和成员函数凑在一起,永远记得这个原则:C++ 中用类来表示概念。”
zero 点了点头。
“我来问你,connection 这个类应该表示什么概念?”
“呃,应该表示‘链接’这个概念。”
“一个 connection 类的对象应该代表 ……”
“应该代表一个实际‘链接’。”
“很好。那么你告诉我,你刚才努力想设计出的那个拷贝构造函数要干什么?”
“ …… 让两个 connection 对象能够表示同一链接。”
“所以 ……”
“ …… 所以 …… 嗯 …… 哦 …… ”zero 露出了恍然大悟的表情:“所以我实际上想做的是要表达这样一个概念:如果一个 connection 对象没有被拷贝,它就表示一个独立的链接,如果它被拷贝了,那么它就和拷贝者表示同一个链接,这也包括拷贝者的拷贝者,拷贝者的拷贝者的拷贝者 …… 天哪,这根本是一团乱麻!”
“对,问题就在这里。一个 connection 对象代表什么?你试图给出一个在逻辑上非常混乱的答案,这导致了实现的复杂性。实际上,如果理清这个逻辑,问题是很简单的:一个 connection 对象代表一个链接,它构造,代表建立了一个链接;它析构,代表这个链接走完了它的生命历程 ——— 这里 open 和 close 这两个成员函数根本就是多余的。至于拷贝构造 ……”
Solmyr 顿了顿,以一种斩钉截铁式的语气说到:
“应该禁止。”
“禁止拷贝?!”
“对,应该禁止。事实上,对于‘链接’这个概念而言,‘拷贝’动作含义模糊:拷贝意味着什么?拷贝构造的对象所表示的链接和原来的链接是什么关系?当使用 connection 类的程序员看到 connection c2 = c1; 这样的代码时,他没法从代码本身看出这是什么意思,他会猜测,c1 和 c2 代表的是一个链接?还是两个链接?只能通过查阅文档来解决,这加重了使用者的负担,而如果禁止拷贝,所有智力正常的程序员都会明白每个 connection 对象唯一的代表一个链接。”
zero 若有所思的点了点头。
“同时,这还能阻止程序员用传值方式向函数传递 connection 对象 ——— 想象一下,如果一个程序员这样使用 connection ,会发生什么?”,Solmyr 键入了下面的代码:
void send_a_greeting(connection c) { c.write("Hello!"); }
zero 没费什么劲就看出了问题:“函数的设计者以为他是在向调用者传入的链接发送消息,但实际上这个函数在按值传递参数的时候创建了一个新链接。”
Solmyr 点了点头,继续说到:“还有,从扩展性的角度考虑,也应该禁止拷贝。比如,假设你将来打算控制链接的创建,把创建过程封装起来,那么这个拷贝构造函数就在你的封装上捅了一个大窟窿 ——— 每个人都可以很方便的利用拷贝构造任意创建链接;又比如,假设将来你需要支持多个类型的链接,要把 connection 作为一个类层次的接口基类,那时,connection 的拷贝构造就必须要禁止,而你之前支持拷贝构造带来的代价就是辛苦的翻遍之前所有的代码去掉所有拷贝构造。”
“那,如果我确实需要在多处访问一个链接,该 ……” zero 没等 Solmyr 回答,自己就接了上去,“呃,也很简单,只要传递引用就可以了,或者如果需要更好的控制,可以用智能指针什么的。”
“完全正确。说起来,其实许多类 ——— 比许多人所认为的要多的多 ——— 所表示的概念对于‘拷贝’这个动作都没有清楚的定义,比如常见的‘窗口’、‘文件’、‘事务’等等等等,禁止它们拷贝往往可以让代码的逻辑清楚许多。以后你在设计类的时候,完全可以首先考虑是否禁止它的拷贝构造,如果不能禁止,再去考虑怎么写拷贝构造函数的问题。好了 zero ,现在你能给出 connection 的实现吗?”
“Sure!只要将拷贝构造函数和重载赋值运算符设为私有,就可以禁止拷贝了。”zero 拖过键盘,三两下屏幕上就出现了一个新的实现:
class connection { private: conn_attr m_attr; int m_bufsize; char* m_buf; conn_handle m_h; ... public: connection(const conn_attr& attr, int bufsize) : m_attr(attr), m_bufsize(bufsize) { m_buf = new char[m_bufsize]; m_h = open_conn(&m_attr, m_buf, m_bufsize); } ~connection() { close_conn(m_h); delete m_buf; } void write(char c){ write_conn(m_h, c); } char read(){ return read_conn(m_h); } ... private: connection(const connection&); connection& operator=(const connection&); };
“嗯,很好,这个问题可以告一段落了。”Solmyr 点了点头,准备离开,但又停了下来:“对了 zero ,pisces 他们这边曾经打报告要求增加人手,从今天的情况来看也确实需要有个懂点 C++ 的人加强这边。我看你正好有空,这个事就你来负责吧。”
zero 心中暗暗叫苦,赶紧分辨:“没有啊 Solmyr,我现在手边的事情多得做不完啊!”
“是吗?哦 …… 对了,我刚才接到网管的报告,说有个人的电脑最近频繁的访问 QQ 的服务器,那个人是谁来着?”,Solmyr 又露出了他招牌式的微笑。
“呃 …… 我又想了想,虽然我确实事情比较多,但团队合作精神还是要发扬的。”
“嗯,这样就好。” Solmyr 心满意足的离开了。
“真见鬼!”确认 Solmyr 走远后,zero 才把在心里憋着的抱怨吐了出来:“好不容易有一段可以休息休息的空档,这下子又泡汤了!真该死。”正在 zero 忿忿不平的时候,一个幽幽的声音从旁边飘了过来:
“zero,刚才你和 Solmyr 讲的什么‘概念’、‘禁止拷贝’、‘类层次’…… 这些都是什么呀?还是你给我讲讲吧 ~~”
zero 转过头,看到 pisces 又在以非常“诚恳”的眼神看着他,再想到自己今后的任务,突然间觉得脑袋隐隐的痛了起来 ——— 他似乎有一点明白了,为什么没事的时候 Solmyr 总在揉自己的太阳穴 ……
Zero
“Shit!又死机了。我已经在这平台上工作了一个月了。可死机的次数比我在这个月里被女孩甩的次数还多。为什么?还不是这该死的平台,为什么掌上系统的内存就和愿意和我说话的女孩一样少?”Solmyr抱怨道。
“兄弟,怎么了。”Zero问。
“Zero是我们这组的主程序员,他懂得很多,人长的也帅,很讨女孩子的欢心。甚至连美工组的Lili(长得比孙燕姿还好看)也暗恋他。”。Solmyr一边想,一边说,“老大,你看,又死机了。为什么我每次用动态内存超过10次,就crash了呢?”
“我知道你原来是Java程序员,也许到现在,你还念念不忘那GC (垃圾收集机制,我说还不如叫上帝也哭泣-God Cry),可你要知道,你现在是在用C++编程,重要的是效率。”
“效率,我……”忽然,Solmyr觉得嘴角边似乎有液体流过。那是Solmyr的口水。每次提到效率,Solmyr总要流口水,就像看到漂亮MM,Solmyr要喷鼻血一样。
“C++中,关于动态内存的是new and delete。”
“我知道,”Solmyr急于表现自己,想证明自己对C++并不是一无所知,“我正在读Scott Meyer的More Effective C++。在C++中,new operator是C++内建的行为。任何人(也许除了Bjarne Stroustrup)都无法改变。new operator先调用一个名为operator new的函数动态申请内存。标准模式就像这样:
void* operator new(size_t size);
然后在传回的void*指针上进行构造的行为。而delete operator则先析构对象,然后调用名为operator delete的函数,标准形式就像这样:
void operator delete(void* pToDeAlloc) // GotW中说即使在指针参数后加上size_t size,仍然是标准形式。 // size_t size的作用是检查所要卸除的内存是否是期望的大小, //如果在类层次中定义的话,只要基类是virtual destructor,那么size可以 // 确保是正确大小。
而placement new……。”
“唔,说得不错,有进步。关于placement new/delete,先知Meyer[1]有详细的论述。原来的placement new仅仅是这样:
void* operator new(size_t, void* pMem) { return pMem; }
‘ 随着时间过去,任何‘要求额外引数’的 operator new 版本,也都渐渐采用 placement new 这个术语。事实上这个术语已经被铭记於 C++ 标准规格中。因此,当 C++ 程序员谈到所谓的 placement new 函数,他们所谈的可能是上述那个‘需要额外一个 void* 参数,用以指出物件置于何处’的版本,但也可能是指那些‘所需参数比单一而必要之 size_t 引数更多’的任何 operator new 版本,包括上述函数,也包括其他‘参数更多’的 operator new 函数。’——引自[1]。
现在我们来考虑一个问题,如果在operator new结束申请内存后,构造函数抛出了异常,那么已经申请的内存谁来回收,答案当然是编译器。因为整个new operator还未结束。所以你无法获得控制权。如果你对operator new/delete进行了重载,那么编译器会调用那个operator delete呢?由于不同的operator new可能通过不同的方法获得内存,而让不知道怎样分配的operator delete去释放内存显然是不负责任的。所以编译器假定哪个operator delete与operator new有相同的参数(除了size_t size和void* pDeAlloc),那么那个operator delete便知道operator new怎样获得内存。构造函数抛出异常后,也会调用与operator new相同参数的operator delete来释放内存。”
“那么,我该怎么解决现在的问题呢?”
“别那么急,已经下午五点了。该回家了,别让人以为程序员是夜游神。回家看看Effective C++第二部分和GotW9, GotW10。明天再说吧。”
第二天一早,Solmyr啃着大饼走进了办公室,看到Guru Zero早已姿势优雅地坐在电脑前收发Email,不禁自惭形秽,连忙放下手中的大饼,跑去和Zero说:“老大,昨天晚上,我挑灯夜读,总结出两点:
- 1. 如果你写了一个operator new,请对应写一个operator delete。
- 2. 不要delete不是自己new来的内存。
“嗯,不错,怎么我看上去,就象是Effective C++中的条款呢?你有没有自己想过关于placement delete的问题?”
“placement delete有什么问题吗?”Solmyr一脸茫然的问道。
“你有没有试过把一块用placement new申请得到的内存用placement delete卸除掉呢?不妨你现在试试看。”
只见Solmyr跑到一台电脑前,两手如飞在键盘上敲击,可是我们能听到的只有他的唉声叹气和编译器的哇哇大叫。Solmyr实在是没办法,只能向Zero求教。Zero喝了一口咖啡,说道:“我们平时写的那些要求额外参数的operator delete只有在构造函数抛出异常时,才会被编译器自动调用,而我们是不可能手工调用到任何带有额外参数的delete的,为什么没有一个内建的‘placement delete’来与‘placement new’相匹配的原因在于没有办法保证它被正确使用,在C++类型系统中,无法推断一个指针从哪里获得它指向的内存,可能是指向heap,也可能是stack。所以……”
“所以当我们确实知道一个指针它是怎么获得它所指向的内存时,我们可以这样:
template<class T> void destroy(T* p, Arena& a) { if (p) { p->~T(); // explicit destructor call a.deallocate(p); } }
这样,就不会有资源泄漏了。”[2]
“可是我怎么解决我那稀有的内存的问题呢?”
“标准库中的allocator和boost中的pool可以解决内存管理的问题,不必每次调用operator new,从而少了一些开销,不过早上我刚收到客户的mail,他们说准备在他们的设备上再加12MB的内存。”
“……”Solmyr郁闷中。
[1]Object Counting in C++ CUJ 1998.4 中文译文 陈崴 http://www.jjhou.com/(程序员杂志:2001.8 杂志上少了placement new and placement delete)
[2] Bjarne Stroustrup's C++ Style and Technique FAQ Is there a "placement delete"?
Zero
“Z z z ……”,Solmyr又在打鼾了,虽然说上班时打呼噜是被小组禁止的,但没有人能阻止Solmyr在睡梦中梦想自己成为大虾,教训现实中的大虾Zero。梦中他正扁Zero扁的高兴,口水流了一写字台,没想到突然听到一声“Stupid”,猛地惊醒,看见原来是测试部门发来的Email,抱怨他写的程序通不过测试。
他把程序代码装入了UltraEdit,看了一下,原来是那段字符串处理程序:
void f(string& s1, string& s2) { const char* cs = (s1 + s2).c_str(); cout << cs; }
在他看来,这段程序没什么问题,他试着测试了一下,没什么问题,cs正确的显示了结果,不是么。“该死的测试部门,总是莫名其妙的发来这些毫无意义的邮件……”Solmyr嘴里嘟囔着,突然听到身后传来的声音“注意临时对象的生存期,孩子。”
Solmyr吓了一跳,是Zero,他总是在你受窘的时候出现,并无私的帮助你(虽然偶尔会带几句嘲笑和讽刺),这次他又想怎么样呢?“孩子,你知道临时对象的生存期吗?”“唔,我想,大概是,应该是在退出它的作用域(scope)之后,它被析构吧。”Solmyr脸色苍白,支支吾吾的答道。
“不,不对,他们将会在创建他们的表达式的结尾被析构(“TCPL": a temporary object is destroyed at the end of the full expression in which it was created. A full expression is an expression that is not a subexpression of some other expression),不妨你再运行一下你的程序看看。”
Solmyr又运行了一次,令人惊讶的是这次的结果竟然和上次不一样,太夸张了。这时,Zero的声音又在耳旁响起“现在,说说为什么会是这样。Solmyr想了一下,突然大有领悟的说:“由于s1 + s2所产生的临时对象在表达式结束之后就被析构了,所以cs指向的内存就不一定存在了,可能还是原来的s1+s2,也可能是别的,所以就不能保证显示正确。”
“很好,可情况并不是总是那么简单,C++规定,临时对象可以做为常量引用和命名对象(named object)的初始器(initializer),就像下面一样:
void f(const string&, const string&); void h(string& s1, string& s2) { const string& s = s1 + s2; string ss = s1 + s2; f(s, ss); }
上面的代码将会运行的很好,而临时对象也会在常量引用和命名对象退出他们的作用域后被摧毁。 临时对象常会出现在以下场合:类型转换和函数返回。函数返回值一般能被编译器优化掉,所以你可以不必担心它带来的开销。而类型转换则破费思量,它的目的一般是为了使函数调用能够成功,如下:
void uppercasify(string& str); // changes all chars in str to upper case char subtleBookPlug[] = "Effective C++"; uppercasify(subtleBookPlug); // error!
为什么呢,你能告诉我吗?”
“因为要使函数调用成功,必须将subtleBookPlug转换成string类型,而编译器认为你要改变的subtleBookPlug,而类型转换后将产生一个类型为string的临时对象,而在void uppercasify(string& str)中,被改变的将是这个临时对象,而不是subtleBookPlug,这显然不是程序员所期望的,所以C++明智地禁止了这种行为。”
“很好,今天你表现的很好,我的孩子,但记住,千万不要在背后说测试部门的坏话,否则的话,哼哼……”
注:
本文所有例子均参考了
The C++ Programming Language 3rd
More Effective in C++
如读者觉得没弄明白或没过瘾的话,可以参考TCPL Pg254-255, MEC Item 19, Item 20