昨天的帖子《C++ 对象是怎么死的?进程篇》,在谈到全局对象的析构顺序史,举了一个“在析构函数中使用 cout”的例子(代码详见原文)。当时的本意是想说明:全局对象的析构顺序是不确定的,最好不要在程序逻辑中依赖这个顺序(免得出现移植问题)。
没成想该帖子引来热烈的评论(俺有点受宠若惊了),关注的焦点主要集中在:“cout 是否会最后析构”。有些网友质疑我所提到的有关 VC6 的行为;有网友引用了《TCPPPL》上的相关章节(21.5.2)来说明“cout 会最先构造、最后析构”。既然大伙儿对标准流类库的构造和析构挺有兴趣,咱们今天就针对这个话题补充说明一下。
★标准都说了些啥?
C++ 03 标准(18.3章节)提到了进程启动和终止。其中对进程的自然死亡有比较具体的描述,包括:各种类型的对象啥时候销毁、用 atexit 注册的退出函数啥时候被调用、还有输入输出流啥时候 flush 和 close 等等。
另外,在标准的 27.4.2.1.6章节 阐述了 ios_base::Init 如何通过包含一个引用计数,对八个标准流类库的全局对象进行适当的初始化和销毁。
那么,有了 C++ 03 标准作书面担保,咱们是否就高枕无忧捏?请看俺下面的分析:
★关于老式编译器(03年之前推出)
首先,某些老式编译器在标准发布之前推向市场,因此它们对标准的实现不够好(这不是废话嘛)。关于老式编译器的标准兼容性,在《C++ 移植性和跨平台开发[2]:语法》有进一步说明。
说到“老式编译器”,最典型代表当然就是那个(让人又爱又恨的) VC++ 6.0 啦。其实已经有很多人碰到了“VC6 环境中 cout 提前析构”的问题,不信大伙儿可以在 Google 上搜一下。另外,如果你手头上正好有 VC6,可以试试附在本文末尾的“示例1”的代码,看是否能够正常打印,就知道了。
当然啦,VC6 可以称得上是古董级的编译器了,从1998年推出到现在,已经超过10个年头。所以它对标准的实现不好也无可厚非。
★关于新式编译器(03年之后推出)
刚才批了一通“老式编译器”,那么“新式编译器”是否就完美地支持 C++ 03 标准呢?俺感觉有点玄。所以今天特地试验了手头能找到的几个 C++ 编译器。看看它们是否能够保证 cout 最先构造、最后析构。另外,为了给这些新式编译器增加点挑战,俺把本文附带的“示例1代码”稍微修改了一下,变成“示例2代码”(说实话,这么写代码确实有点夸张),也附在本文末尾。
手头的几种编译器测试下来,结果如下:
![](https://zymtest.com/wp-content/uploads/2024/06/图片-36-1024x185.png)
对于“示例2代码”造成的崩溃,经过简单排查,基本可以推断是 cout 滞后构造导致(也就是用到 cout 时,它还没生出来)。而且 GCC 3.4.2 这个版本是2004年出品的,应该不算太老啊(至少 C++ 03 标准已经发布一年了)。从上面的结果看,Linux 上版本较老的 GCC 反而没有问题。所以俺【猜测】:或许 GCC 在 Windows 上的移植版本有这个缺陷(仅仅是猜测啊)。
由于时间关系,没来得及深入研究。如果有同学不信,可以找个类似的环境试验一下(没准儿最后发现是俺搞错了,也有可能哦)。另外,想打破砂锅的同学,可以去琢磨一下出问题的编译器,看看它们的内部实现。
★总结
根据上述的情况,俺个人建议:如果你的代码有全局对象,并且你的代码【可能】会跨编译器,那就避免在全局对象的构造函数和析构函数中使用标准流类库的那八个玩意儿(包括“cout、cerr、clog”等)。
毕竟这八个全局对象,都有对应的标准 C 替代品,并不是不可替代的嘛。大伙儿犯不着冒险嘛。如果你确实舍不得流式操作符(<< 和 >>)或者确实看不惯 printf 的变参,你可以用字符串流先格式化好,再用标准 C 的函数输出嘛(也就多一两行代码而已嘛)。
★附录:测试代码
最后附上示例代码,供有兴趣的同学尝试。大伙儿如果有新的发现,欢迎在本文留言告知俺。
◇示例1代码
#include <iostream>
using namespace std;
class CFoo
{
public:
CFoo()
{
cout << "CFoo" << endl;
}
~CFoo()
{
cout << "~CFoo" << endl;
}
};
CFoo g_foo;
int main()
{
return 0;
}
◇示例2代码
发表回复