C++
工程实践:慎用匿名 namespace
匿名 namespace (anonymous namespace 或称 unnamed namespace)
是 C++ 的一项非常有用的功能,其主要目的是让该 namespace 中的成员(变量或函数)具有独一无二的全局名称,避免名字碰撞
(name collisions)。一般在编写 .cpp 文件时,如果需要写一些小的 helper 函数,我们常常会放到匿名
namespace 里。muduo 0.1.7 中的 muduo/base/Date.cc 和 muduo/base/Thread.cc
等处就用到了匿名 namespace。
我最近在工作中遇到并重新思考了这一问题,发现匿名 namespace 并不是多多益善。
C 语言的 static 关键字的两种用法
C 语言的 static 关键字有两种用途:
1. 用于函数内部修饰变量,即函数内的静态变量。这种变量的生存期长于该函数,使得函数具有一定的“状态”。使用静态变量的函数一般是不可重入的,也不是线程安全的。
2. 用在文件级别(函数体之外),修饰变量或函数,表示该变量或函数只在本文件可见,其他文件看不到也访问不到该变量或函数。专业的说法叫“具有
internal linkage”(简言之:不暴露给别的 translation unit)。
C 语言的这两种用法很明确,一般也不容易混淆。
C++ 语言的 static 关键字的四种用法
由于 C++ 引入了 class,在保持与 C 语言兼容的同时,static 关键字又有了两种新用法:
3. 用于修饰 class 的数据成员,即所谓“静态成员”。这种数据成员的生存期大于 class 的对象(实体
instance)。静态数据成员是每个 class 有一份,普通数据成员是每个 instance 有一份,因此也分别叫做
class variable 和 instance variable。
4. 用于修饰 class 的成员函数,即所谓“静态成员函数”。这种成员函数只能访问 class variable
和其他静态程序函数,不能访问 instance variable 或 instance method。
当然,这几种用法可以相互组合,比如 C++ 的成员函数(无论 static 还是 instance)都可以有其局部的静态变量(上面的用法
1)。对于 class template 和 function template,其中的 static
对象的真正个数跟 template instantiation (模板具现化)有关,相信学过 C++ 模板的人不会陌生。
可见在 C++ 里 static 被 overload 了多次。匿名 namespace 的引入是为了减轻
static 的负担,它替换了 static 的第 2 种用途。也就是说,在 C++ 里不必使用文件级的
static 关键字,我们可以用匿名 namespace 达到相同的效果。(其实严格地说,linkage
或许稍有不同,这里不展开讨论了。)
匿名 namespace 的不利之处
在工程实践中,匿名 namespace 有两大不利之处:
1.其中的函数难以设断点,如果你像我一样使用的是 gdb 这样的文本模式 debugger。
2.使用某些版本的 g++ 时,同一个文件每次编译出来的二进制文件会变化,这让某些 build tool
失灵。
考虑下面这段简短的代码 (anon.cc):
1: namespace
2: {
3: void foo()
4: {
5: }
6: }
7:
8: int main()
9: {
10: foo();
11: }
对于问题 1:
gdb 的<tab>键自动补全功能能帮我们设定断点,不是什么大问题。前提是你知道那个"(anonymous
namespace)::foo()"正是你想要的函数。
麻烦的是,如果两个文件 anon.cc 和 anonlib.cc 都定义了匿名空间中的 foo() 函数(这不会冲突),那么
gdb 无法区分这两个函数,你只能给其中一个设断点。或者你使用 文件名:行号 的方式来分别设断点。(从技术上,匿名
namespace 中的函数是 weak text,链接的时候如果发生符号重名,linker 不会报错。
从根本上解决的办法是使用普通具名 namespace,如果怕重名,可以把源文件名(必要时加上路径)作为
namespace 名字的一部分。
对于问题 2:
把它编译两次,分别生成 a.out 和 b.out:
$ g++ -g -o a.out anon.cc
$ g++ -g -o b.out anon.cc
$ md5sum a.out b.out
0f7a9cc15af7ab1e57af17ba16afcd70 a.out
8f22fc2bbfc27beb922aefa97d174e3b b.out
$ g++ --version
g++ (GCC) 4.2.4 (Ubuntu 4.2.4-1ubuntu4)
$ diff -u <(nm a.out) <(nm b.out)
--- /dev/fd/63 2011-02-15 22:27:58.960754999 +0800
+++ /dev/fd/62 2011-02-15 22:27:58.960754999 +0800
@@ -2,7 +2,7 @@
0000000000600940 d _GLOBAL_OFFSET_TABLE_
0000000000400634 R _IO_stdin_used
w _Jv_RegisterClasses
-0000000000400538 t _ZN36_GLOBAL__N_anon.cc_00000000_E2CEEB513fooEv
+0000000000400538 t _ZN36_GLOBAL__N_anon.cc_00000000_CB51498D3fooEv
0000000000600748 d __CTOR_END__
0000000000600740 d __CTOR_LIST__
0000000000600758 d __DTOR_END__
由上可见,g++ 4.2.4 会随机地给匿名 namespace 生成一个惟一的名字(foo() 函数的
mangled name 中的 E2CEEB51 和 CB51498D 是随机的),以保证名字不冲突。也就是说,同样的源文件,两次编译得到的二进制文件内容不相同,这有时候会造成问题。比如说拿到一个会发生
core dump 的二进制可执行文件,无法确定它是由哪个 revision 的代码编译出来的。毕竟编译结果不可复现,具有一定的随机性。
这可以用 gcc 的 -frandom-seed 参数解决,具体见文档。
这个现象在 gcc 4.2.4 中存在(之前的版本估计类似),在 gcc 4.4.5 中不存在。
替代办法
如果前面的“不利之处”给你带来困扰,解决办法也很简单,就是使用普通具名 namespace。当然,要起一个好的名字,比如
boost 里就常常用 boost::detail 来放那些“不应该暴露给客户,但又不得不放到头文件里”的函数或
class。
总而言之,匿名 namespace 没什么大问题,使用它也不是什么过错。万一它碍事了,可以用普通具名
namespace 替代之。
C++ 工程实践:不要重载全局 ::operator new()
本文只考虑 Linux x86 平台,服务端开发(不考虑 Windows 的跨 DLL 内存分配释放问题)。本文假定读者知道
::operator new() 和 ::operator delete() 是干什么的,与通常用的 new/delete
表达式有和区别和联系,这方面的知识可参考侯捷先生的文章《池内春秋》[1],或者这篇文章。
C++ 的内存管理是个老生常谈的话题,我在《当析构函数遇到多线程》第 7 节“插曲:系统地避免各种指针错误”中简单回顾了一些常见的问题以及在现代
C++ 中的解决办法。基本上,按现代 C++ 的手法(RAII)来管理内存,你很难遇到什么内存方面的错误。“没有错误”是基本要求,不代表“足够好”。我们常常会设法优化性能,如果
profiling 表明 hot spot 在内存分配和释放上,重载全局的 ::operator new()
和 ::operator delete() 似乎是一个一劳永逸好办法(以下简写为“重载 ::operator
new()”),本文试图说明这个办法往往行不通。
内存管理的基本要求
如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复 delete,也不漏掉 delete。也就说我们常说的
new/delete 要配对,“配对”不仅是个数相等,还隐含了 new 和 delete 的调用本身要匹配,不要“东家借的东西西家还”。例如:
用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放;
用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并释放;
用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析构并释放;
用系统默认的 ::operator new() 分配的的内存要交给系统默认的 ::operator delete()
去释放;
用 placement new 创建的对象要用 placement delete (为了表述方便,姑且这么说吧)去析构(其实就是直接调用析构函数);
从某个内存池 A 分配的内存要还给这个内存池。
如果定制 new/delete,那么要按规矩来。见 Effective C++ 相关条款。
做到以上这些不难,是每个 C++ 开发人员的基本功。不过,如果你想重载全局的 ::operator new(),事情就麻烦了。
重载 ::operator new() 的理由
Effective C++ 第三版第 50 条列举了定制 new/delete 的几点理由:
检测代码中的内存错误
优化性能
获得内存使用的统计数据
这些都是正当的需求,文末我们将会看到,不重载 ::operator new() 也能达到同样的目的。
::operator new() 的两种重载方式
1. 不改变其签名,无缝直接替换系统原有的版本,例如=
#include <new>
void* operator new(size_t size);
void operator delete(void* p);
用这种方式的重载,使用方不需要包含任何特殊的头文件,也就是说不需要看见这两个函数声明。“性能优化”通常用这种方式。
2. 增加新的参数,调用时也提供这些额外的参数,例如:
void* operator new(size_t size, const char* file, int
line); // 其返回的指针必须能被普通的 ::operator delete(void*) 释放
void operator delete(void* p, const char* file, int
line); // 这个函数只在析构函数抛异常的情况下才会被调用
然后用的时候是
Foo* p = new (__FILE, __LINE__) Foo; // 这样能跟踪是哪个文件哪一行代码分配的内存
我们也可以用宏替换 new 来节省打字。用这第二种方式重载,使用方需要看到这两个函数声明,也就是说要主动包含你提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。当然,这不是绝对的。
在学习 C++ 的阶段,每个人都可以写个一两百行的程序来验证教科书上的说法,重载 ::operator
new() 在这样的玩具程序里边不会造成什么麻烦。
不过,我认为在现实的产品开发中,重载 ::operator new() 乃是下策,我们有更简单安全的办法来到达以上目标。
现实的开发环境
作为 C++ 应用程序的开发人员,在编写稍具规模的程序时,我们通常会用到一些 library。我们可以根据
library 的提供方把它们大致分为这么几大类:
C 语言的标准库,也包括 Linux 编程环境提供的 Posix 系列函数。
第三方的 C 语言库,例如 OpenSSL。
C++ 语言的标准库,主要是 STL。(我想没有人在产品中使用 IOStream 吧?)
第三方的通用 C++ 库,例如 Boost.Regex,或者某款 XML 库。
公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换模块。
在使用这些 library 的时候,不可避免地要在各个 library 之间交换数据。比方说 library
A 的输出作为 library B 的输入,而 library A 的输出本身常常会用到动态分配的内存(比如
std::vector<double>)。
如果所有的 C++ library 都用同一套内存分配器(就是系统默认的 new/delete ),那么内存的释放就很方便,直接交给
delete 去释放就行。如果不是这样,那就得时时刻刻记住“这一块内存是属于哪个分配器,是系统默认的还是我们定制的,释放的时候不要还错了地方”。
由于 C 语言不像 C++ 一样提过了那么多的定制性,C library 通常都会默认直接用 malloc/free
来分配和释放内存,不存在上面提到的“内存还错地方”问题。或者有的考虑更全面的 C library 会让你注册两个函数,用于它内部分配和释放内存,这就就能完全掌控该
library 的内存使用。这种依赖注入的方式在 C++ 里变得花哨而无用,见陈硕写的《C++ 标准库中的allocator是多余的》。
但是,如果重载了 ::operator new(),事情恐怕就没有这么简单了。
重载 ::operator new() 的困境
首先,重载 ::operator new() 不会给 C 语言的库带来任何麻烦,当然,重载它得到的三点好处也无法让
C 语言的库享受到。
以下仅考虑 C++ library 和 C++ 主程序。
规则 1:绝对不能在 library 里重载 ::operator new()
如果你是某个 library 的作者,你的 library 要提供给别人使用,那么你无权重载全局 ::operator
new(size_t) (注意这是上面提到的第一种重载方式),因为这非常具有侵略性:任何用到你的 library
的程序都被迫使用了你重载的 ::operator new(),而别人很可能不愿意这么做。另外,如果有两个
library 都试图重载 ::operator new(size_t),那么它们会打架,我估计会发生
duplicated symbol link error。干脆,作为 library 的编写者,大家都不要重载
::operator new(size_t) 好了。
那么第二种重载方式呢?首先,::operator new(size_t size, const char*
file, int line) 这种方式得到的 void* 指针必须同时能被 ::operator delete(void*)
和 ::operator delete(void* p, const char* file, int line)
这两个函数释放。这时候你需要决定,你的 ::operator new(size_t size, const
char* file, int line) 返回的指针是不是兼容系统默认的 ::operator delete(void*)。
如果不兼容(也就是说不能用系统默认的 ::operator delete(void*) 来释放内存),那么你得重载
::operator delete(void*),让它的行为与你的 operator new(size_t
size, const char* file, int line) 匹配。一旦你决定重载 ::operator
delete(void*),那么你必须重载 ::operator new(size_t),这就回到了情况
1:你无权重载全局 ::operator new(size_t)。
如果选择兼容系统默认的 ::operator delete(void*),那么你在 operator new(size_t
size, const char* file, int line) 里能做的事情非常有限,比方说你不能额外动态分配内存来做
house keeping 或保存统计数据(无论显示还是隐式),因为系统默认的 ::operator delete(void*)
不会释放你额外分配的内存。(这里隐式分配内存指的是往 std::map<> 这样的容器里添加元素。)
看到这里估计很多人已经晕了,但这还没完。
其次,在 library 里重载 operator new(size_t size, const char*
file, int line) 还涉及到你的重载要不要暴露给 library 的使用者(其他 library
或主程序)。这里“暴露”有两层意思:1) 包含你的头文件的代码会不会用你重载的 ::operator new(),2)
重载之后的 ::operator new() 分配的内存能不能在你的 library 之外被安全地释放。如果不行,那么你是不是要暴露某个接口函数来让使用者安全地释放内存?或者返回
shared_ptr ,利用其“捕获”deleter 的特性?听上去好像挺复杂?这里就不一一展开讨论了,总之,作为
library 的作者,绝对不要动“重载 operator new()”的念头。
事实 2:在主程序里重载 ::operator new() 作用不大
这不是一条规则,而是我试图说明这么做没有多大意义。
如果用第一种方式重载全局 ::operator new(size_t),会影响本程序用到的所有 C++
library,这么做或许不会有什么问题,不过我建议你使用下一节介绍的更简单的“替代办法”。
如果用第二种方式重载 ::operator new(size_t size, const char*
file, int line),那么你的行为是否惠及本程序用到的其他 C++ library 呢?比方说你要不要统计
C++ library 中的内存使用情况?如果某个 library 会返回它自己用 new 分配的内存和对象,让你用完之后自己释放,那么是否打算对错误释放内存做检查?
C++ library 从代码组织上有两种形式:1) 以头文件方式提供(如以 STL 和 Boost
为代表的模板库);2) 以头文件+二进制库文件方式提供(大多数非模板库以此方式发布)。
对于纯以头文件方式实现的 library,那么你可以在你的程序的每个 .cpp 文件的第一行包含重载
::operator new 的头文件,这样程序里用到的其他 C++ library 也会转而使用你的
::operator new 来分配内存。当然这是一种相当有侵略性的做法,如果运气好,编译和运行都没问题;如果运气差一点,可能会遇到编译错误,这其实还不算坏事;运气更差一点,编译没有错误,运行的时候时不时出现非法访问,导致
segment fault;或者在某些情况下你定制的分配策略与 library 有冲突,内存数据损坏,出现莫名其妙的行为。
对于以库文件方式实现的 library,这么做并不能让其受惠,因为 library 的源文件已经编译成了二进制代码,它不会调用你新重载的
::operator new(想想看,已经编译的二进制代码怎么可能提供额外的 new (__FILE__,
__LINE__) 参数呢?)更麻烦的是,如果某些头文件有 inline function,还会引起诡异的“串扰”。即
library 有的部分用了你的分配器,有的部分用了系统默认的分配器,然后在释放内存的时候没有给对地方,造成分配器的数据结构被破坏。
总之,第二种重载方式看似功能更丰富,但其实与程序里使用的其他 C++ library
很难无缝配合。
综上,对于现实生活中的 C++ 项目,重载 ::operator new() 几乎没有用武之地,因为很难处理好与程序所用的
C++ library 的关系,毕竟大多数 library 在设计的时候没有考虑到你会重载 ::operator
new() 并强塞给它。
如果确实需要定制内存分配,该如何办?
替代办法
很简单,替换 malloc。如果需要,直接从 malloc 层面入手,通过 LD_PRELOAD 来加载一个
.so,其中有 malloc/free 的替代实现(drop-in replacement),这样能同时为
C 和 C++ 代码服务,而且避免 C++ 重载 ::operator new() 的阴暗角落。
对于“检测内存错误”这一用法,我们可以用 valgrind 或者 dmalloc 或者 efence
来达到相同的目的,专业的除错工具比自己山寨一个内存检查器要靠谱。
对于“统计内存使用数据”,替换 malloc 同样能得到足够的信息,因为我们可以用 backtrace()
函数来获得调用栈,这比 new (__FILE__, __LINE__) 的信息更丰富。比方说你通过分析
(__FILE__, __LINE__) 发现 std::string 大量分配释放内存,有超出预期的开销,但是你却不知道代码里哪一部分在反复创建和销毁
std::string 对象,因为 (__FILE__, __LINE__) 只能告诉你最内层的调用函数。用
backtrace() 能找到真正的发起调用者。
对于“性能优化”这一用法,我认为这目前的多线程开发中,自己实现一个能打败系统默认的 malloc 的内存分配器是不现实的。一个通用的内存分配器本来就有相当的难度,为多线程程序实现一个安全和高效的通用(全局)内存分配器超出了一般开发人员的能力。不如使用现有的针对多核多线程优化的
malloc,例如 Google tcmalloc 和 Intel TBB 2.2 里的内存分配器。好在这些
allocator 都不是侵入式的,也无须重载 ::operator new()。
为单独的 class 重载 operator new() 有问题吗?
与全局 ::operator new() 不同,per-class operator new() 和 operator
delete () 的影响面要小得多,它只影响本 class 及其派生类。似乎重载 member operator
new() 是可行的。我对此持反对态度。
如果一个 class Node 需要重载 member operator new(),说明它用到了特殊的内存分配策略,常见的情况是使用了内存池或对象池。我宁愿把这一事实明显地摆出来,而不是改变
new Node 的默认行为。具体地说,是用 factory 来创建对象,比如 static Node*
Node::createNode() 或者 static shared_ptr<Node>
Node::createNode();。
这可以归结为最小惊讶原则:如果我在代码里读到 Node* p = new Node,我会认为它在 heap
上分配了内存,如果 Node class 重载了 member operator new(),那么我要事先仔细阅读
node.h 才能发现其实这行代码使用了私有的内存池。为什么不写得明确一点呢?写成 Node* p =
Node::createNode(),那么我能猜到 Node::createNode() 肯定做了什么与
new Node 不一样的事情,免得将来大吃一惊。
The Zen of Python 说 explicit is better than implicit,我深信不疑。
总结:重载 ::operator new() 或许在某些临时的场合能应个急,但是不应该作为一种策略来使用。如果需要,我们可以从
malloc 层面入手,彻底而全面地替换内存分配器。
|