C++ 工程实践(5):避免使用虚函数作为库的接口
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
摘要:作为 C++ 动态库的作者,应当避免使用虚函数作为库的接口。这么做会给保持二进制兼容性带来很大麻烦,不得不增加很多不必要的 interfaces,最终重蹈 COM 的覆辙。
本文主要讨论 Linux x86 平台,会继续举 Windows/COM 作为反面教材。
本文是上一篇《C++ 工程实践(4):二进制兼容性》的延续,在写这篇文章的时候,我原本以外大家都对“以虚函数作为接口”的害处达成共识,我就写得比较简略,看来情况不是这样,我还得展开谈一谈。
“接口”有广义和狭义之分,本文用中文“接口”表示广义的接口,即一个库的代码界面;用英文 interface 表示狭义的接口,即只包含 virtual function 的 class,这种 class 通常没有 data member,在 Java 里有一个专门的关键字 interface 来表示它。
C++ 程序库的作者的生存环境
假设你是一个 shared library 的维护者,你的 library 被公司另外两三个团队使用了。你发现了一个安全漏洞,或者某个会导致 crash 的 bug 需要紧急修复,那么你修复之后,能不能直接部署 library 的二进制文件?有没有破坏二进制兼容性?会不会破坏别人团队已经编译好的投入生成环境的可执行文件?是不是要强迫别的团队重新编译链接,把可执行文件也发布新版本?会不会打乱别人的 release cycle?这些都是工程开发中经常要遇到的问题。
如果你打算新写一个 C++ library,那么通常要做以下几个决策:
- 以什么方式发布?动态库还是静态库?(本文不考虑源代码发布这种情况,这其实和静态库类似。)
- 以什么方式暴露库的接口?可选的做法有:以全局(含 namespace 级别)函数为接口、以 class 的 non-virtual 成员函数为接口、以 virtual 函数为接口(interface)。
(Java 程序员没有这么多需要考虑的,直接写 class 成员函数就行,最多考虑一下要不要给 method 或 class 标上 final。也不必考虑动态库静态库,都是 .jar 文件。)
在作出上面两个决策之前,我们考虑两个基本假设:
- 代码会有 bug,库也不例外。将来可能会发布 bug fixes。
- 会有新的功能需求。写代码不是一锤子买卖,总是会有新的需求冒出来,需要程序员往库里增加东西。这是好事情,让程序员不丢饭碗。
(如果你的代码第一次发布的时候就已经做到完美,将来不需要任何修改,那么怎么做都行,也就不必继续阅读本文。)
也就是说,在设计库的时候必须要考虑将来如何升级。
基于以上两个基本假设来做决定。第一个决定很好做,如果需要 hot fix,那么只能用动态库;否则,在分布式系统中使用静态库更容易部署,这在前文中已经谈过。(“动态库比静态库节约内存”这种优势在今天看来已不太重要。)
以下本文假定你或者你的老板选择以动态库方式发布,即发布 .so 或 .dll 文件,来看看第二个决定怎么做。(再说一句,如果你能够以静态库方式发布,后面的麻烦都不会遇到。)
第二个决定不是那么容易做,关键问题是,要选择一种可扩展的 (extensible) 接口风格,让库的升级变得更轻松。“升级”有两层意思:
- 对于 bug fix only 的升级,二进制库文件的替换应该兼容现有的二进制可执行文件,二进制兼容性方面的问题已经在前文谈过,这里从略。
- 对于新增功能的升级,应该对客户代码的友好。升级库之后,客户端使用新功能的代价应该比较小。只需要包含新的头文件(这一步都可以省略,如果新功能已经加入原有的头文件中),然后编写新代码即可。而且,不要在客户代码中留下垃圾,后文我们会谈到什么是垃圾。
在讨论虚函数接口的弊端之前,我们先看看虚函数做接口的常见用法。
虚函数作为库的接口的两大用途
虚函数为接口大致有这么两种用法:
- 调用,也就是库提供一个什么功能(比如绘图 Graphics),以虚函数为接口方式暴露给客户端代码。客户端代码一般不需要继承这个 interface,而是直接调用其 member function。这么做据说是有利于接口和实现分离,我认为纯属脱了裤子放屁。
- 回调,也就是事件通知,比如网络库的“连接建立”、“数据到达”、“连接断开”等等。客户端代码一般会继承这个 interface,然后把对象实例注册到库里边,等库来回调自己。一般来说客户端不会自己去调用这些 member function,除非是为了写单元测试,模拟库的行为。
- 混合,一个 class 既可以被客户端代码继承用作回调,又可以被客户端直接调用。说实话我没看出这么做的好处,但实际中某些面向对象的 C++ 库就是这么设计的。
对于“回调”方式,现代 C++ 有更好的做法,即 boost::function + boost::bind,见参考文献[4],muduo 的回调全部采用这种新方法,见《Muduo 网络编程示例之零:前言》。本文以下不考虑以虚函数为回调的过时的做法。
对于“调用”方式,这里举一个虚构的图形库,这个库的功能是画线、画矩形、画圆弧:
1: struct Point
2: {
3: int x;
4: int y;
5: };
6:
7: class Graphics
8: {
9: virtual void drawLine(int x0, int y0, int x1, int y1);
10: virtual void drawLine(Point p0, Point p1);
11:
12: virtual void drawRectangle(int x0, int y0, int x1, int y1);
13: virtual void drawRectangle(Point p0, Point p1);
14:
15: virtual void drawArc(int x, int y, int r);
16: virtual void drawArc(Point p, int r);
17: };
这里略去了很多与本文主题无关细节,比如 Graphics 的构造与析构、draw*() 函数应该是 public、Graphics 应该不允许复制,还比如 Graphics 可能会用 pure virtual functions 等等,这些都不影响本文的讨论。
这个 Graphics 库的使用很简单,客户端看起来是这个样子。
Graphics* g = getGraphics();
g->drawLine(0, 0, 100, 200);
releaseGraphics(g); g = NULL;
似乎一切都很好,阳光明媚,符合“面向对象的原则”,但是一旦考虑升级,事情立刻复杂起来。
虚函数作为接口的弊端
以虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”。
假如我需要给 Graphics 增加几个绘图函数,同时保持二进制兼容性。这几个新函数的坐标以浮点数表示,我理想中的新接口是:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800 +++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800 @@ -7,11 +7,14 @@ class Graphics { virtual void drawLine(int x0, int y0, int x1, int y1); + virtual void drawLine(double x0, double y0, double x1, double y1); virtual void drawLine(Point p0, Point p1); virtual void drawRectangle(int x0, int y0, int x1, int y1); + virtual void drawRectangle(double x0, double y0, double x1, double y1); virtual void drawRectangle(Point p0, Point p1); virtual void drawArc(int x, int y, int r); + virtual void drawArc(double x, double y, double r); virtual void drawArc(Point p, int r); };
受 C++ 二进制兼容性方面的限制,我们不能这么做。其本质问题在于 C++ 以 vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确定的,这造成了脆弱性。我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧的 offset 调用到正确的函数。
怎么办呢?有一种危险且丑陋的做法:把新的虚函数放到 interface 的末尾,例如:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800 +++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800 @@ -7,11 +7,15 @@ class Graphics { virtual void drawLine(int x0, int y0, int x1, int y1); virtual void drawLine(Point p0, Point p1); virtual void drawRectangle(int x0, int y0, int x1, int y1); virtual void drawRectangle(Point p0, Point p1); virtual void drawArc(int x, int y, int r); virtual void drawArc(Point p, int r); + + virtual void drawLine(double x0, double y0, double x1, double y1); + virtual void drawRectangle(double x0, double y0, double x1, double y1); + virtual void drawArc(double x, double y, double r); };
这么做很丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数没有和原来的 drawLine() 函数呆在一起,造成阅读上的不便。这么做同时很危险,因为 Graphics 如果被继承,那么新增虚函数会改变派生类中的 vtable offset 变化,同样不是二进制兼容的。
另外有两种似乎安全的做法,这也是 COM 采用的办法:
1. 通过链式继承来扩展现有 interface,例如
--- graphics.h 2011-03-12 13:12:44.000000000 +0800 +++ graphics2.h 2011-03-12 13:58:35.000000000 +0800 @@ -7,11 +7,19 @@ class Graphics { virtual void drawLine(int x0, int y0, int x1, int y1); virtual void drawLine(Point p0, Point p1); virtual void drawRectangle(int x0, int y0, int x1, int y1); virtual void drawRectangle(Point p0, Point p1); virtual void drawArc(int x, int y, int r); virtual void drawArc(Point p, int r); }; + +class Graphics2 : public Graphics +{ + using Graphics::drawLine; + using Graphics::drawRectangle; + using Graphics::drawArc; + + // added in version 2 + virtual void drawLine(double x0, double y0, double x1, double y1); + virtual void drawRectangle(double x0, double y0, double x1, double y1); + virtual void drawArc(double x, double y, double r); +};
将来如果继续增加功能,那么还会有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。这么做和前面的做法一样丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数位于派生 Graphics2 interace 中,没有和原来的 drawLine() 函数呆在一起,造成割裂。
2. 通过多重继承来扩展现有 interface,例如定义一个与 Graphics class 有同样成员的 Graphics2
--- graphics.h 2011-03-12 13:12:44.000000000 +0800 +++ graphics2.h 2011-03-12 13:16:45.000000000 +0800 @@ -7,11 +7,32 @@ class Graphics { virtual void drawLine(int x0, int y0, int x1, int y1); virtual void drawLine(Point p0, Point p1); virtual void drawRectangle(int x0, int y0, int x1, int y1); virtual void drawRectangle(Point p0, Point p1); virtual void drawArc(int x, int y, int r); virtual void drawArc(Point p, int r); }; + +class Graphics2 +{ + virtual void drawLine(int x0, int y0, int x1, int y1); + virtual void drawLine(double x0, double y0, double x1, double y1); + virtual void drawLine(Point p0, Point p1); + + virtual void drawRectangle(int x0, int y0, int x1, int y1); + virtual void drawRectangle(double x0, double y0, double x1, double y1); + virtual void drawRectangle(Point p0, Point p1); + + virtual void drawArc(int x, int y, int r); + virtual void drawArc(double x, double y, double r); + virtual void drawArc(Point p, int r); +}; + +// 在实现中采用多重接口继承 +class GraphicsImpl : public Graphics, // version 1 + public Graphics2, // version 2 +{ + // ... +};
这种带版本的 interface 的做法在 COM 使用者的眼中看起来是很正常的,解决了二进制兼容性的问题,客户端源代码也不受影响。
在我看来带版本的 interface 实在是很丑陋,因为每次改动都引入了新的 interface class,会造成日后客户端代码难以管理。比如,如果代码使用了 Graphics3 的功能,要不要把现有的 Graphics2 都替换掉?
- 如果不替换,一个程序同时依赖多个版本的 Graphics,一直背着历史包袱。依赖的 Graphics 版本愈来愈多,将来如何管理得过来?
- 如果要替换,为什么不相干的代码(现有的运行得好好的使用 Graphics2 的代码)也会因为别处用到了 Graphics3 而被修改?
这种二难境地纯粹是“以虚函数为库的接口”造成的。如果我们能直接原地扩充 class Graphics,就不会有这些屁事,见本文“推荐做法”一节。
假如 Linux 系统调用以 COM 接口方式实现
或许上面这个 Graphics 的例子太简单,没有让“以虚函数为接口”的缺点充分暴露出来,让我们看一个真实的案例:Linux Kernel。
Linux kernel 从 0.10 的 67 个系统调用发展到 2.6.37 的 340 个,kernel interface 一直在扩充,而且保持良好的兼容性,它保持兼容性的办法很土,就是给每个 system call 赋予一个终身不变的数字代号,等于把虚函数表的排列固定下来。点开本段开头的两个链接,你就能看到 fork() 在 Linux 0.10 和 Linux 2.6.37 里的代号都是 2。(系统调用的编号跟硬件平台有关,这里我们看的是 x86 32-bit 平台。)
试想假如 Linus 当初选择用 COM 接口的链式继承风格来描述,将会是怎样一种壮观的景象?为了避免扰乱视线,请移步观看近百层继承的代码。(先后关系与版本号不一定 100% 准确,我是用 git blame 去查的,现在列出的代码只从 0.01 到 2.5.31,相信已经足以展现 COM 接口方式的弊端。)
不要误认为“接口一旦发布就不能更改”是天经地义的,那不过是“以 C++ 虚函数为接口”的固有弊端,如果跳出这个框框去思考,其实 C++ 库的接口很容易做得更好。
为什么不能改?还不是因为用了C++ 虚函数作为接口。Java 的 interface 可以添加新函数,C 语言的库也可以添加新的全局函数,C++ class 也可以添加新 non-virtual 成员函数和 namespace 级别的 non-member 函数,这些都不需要继承出新 interface 就能扩充原有接口。偏偏 COM 的 interface 不能原地扩充,只能通过继承来 workaround,产生一堆带版本的 interfaces。有人说 COM 是二进制兼容性的正面例子,某深不以为然。COM 确实以一种最丑陋的方式做到了“二进制兼容”。脆弱与僵硬就是以 C++ 虚函数为接口的宿命。
相反,Linux 系统调用以编译期常数方式固定下来,万年不变,轻而易举地解决了这个问题。在其他面向对象语言(Java/C#)中,我也没有见过每改动一次就给 interface 递增版本号的做法。
还是应了《The Zen of Python》中的那句话,Explicit is better than implicit, Flat is better than nested.
动态库的接口的推荐做法
取决于动态库的使用范围,有两类做法。
如果,动态库的使用范围比较窄,比如本团队内部的两三个程序在用,用户都是受控的,要发布新版本也比较容易协调,那么不用太费事,只要做好发布的版本管理就行了。再在可执行文件中使用 rpath 把库的完整路径确定下来。
比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二进制兼容。用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制兼容,而 1.2.1 应该和 1.2.0 兼容。如果要加入新的功能,而新的功能与 1.2.0 不兼容,那么应该发布到 1.3.0 版本。
为了便于检查二进制兼容性,可考虑把库的代码的暴露情况分辨清楚。muduo 的头文件和 class 就有意识地分为用户可见和用户不可见两部分,见 http://blog.csdn.net/Solstice/archive/2010/08/29/5848547.aspx#_Toc32039。对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。另外 muduo 本身设计来是以静态库方式发布,在二进制兼容性方面没有做太多的考虑。
如果库的使用范围很广,用户很多,各家的 release cycle 不尽相同,那么推荐 pimpl 技法[2, item 43],并考虑多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作为接口。这里以前面的 Graphics 为例,说明 pimpl 的基本手法。
1. 暴露的接口里边不要有虚函数,而且 sizeof(Graphics) == sizeof(Graphics::Impl*)。
class Graphics { public: Graphics(); // outline ctor ~Graphics(); // outline dtor void drawLine(int x0, int y0, int x1, int y1); void drawLine(Point p0, Point p1); void drawRectangle(int x0, int y0, int x1, int y1); void drawRectangle(Point p0, Point p1); void drawArc(int x, int y, int r); void drawArc(Point p, int r); private: class Impl; boost::scoped_ptr<Impl> impl; };
2. 在库的实现中把调用转发 (forward) 给实现 Graphics::Impl ,这部分代码位于 .so/.dll 中,随库的升级一起变化。
#include <graphics.h> class Graphics::Impl { public: void drawLine(int x0, int y0, int x1, int y1); void drawLine(Point p0, Point p1); void drawRectangle(int x0, int y0, int x1, int y1); void drawRectangle(Point p0, Point p1); void drawArc(int x, int y, int r); void drawArc(Point p, int r); }; Graphics::Graphics() : impl(new Impl) { } Graphics::~Graphics() { } void Graphics::drawLine(int x0, int y0, int x1, int y1) { impl->drawLine(x0, y0, x1, y1); } void Graphics::drawLine(Point p0, Point p1) { impl->drawLine(p0, p1); } // ...
3. 如果要加入新的功能,不必通过继承来扩展,可以原地修改,且保持二进制兼容性。先动头文件:
--- old/graphics.h 2011-03-12 15:34:06.000000000 +0800 +++ new/graphics.h 2011-03-12 15:14:12.000000000 +0800 @@ -7,19 +7,22 @@ class Graphics { public: Graphics(); // outline ctor ~Graphics(); // outline dtor void drawLine(int x0, int y0, int x1, int y1); + void drawLine(double x0, double y0, double x1, double y1); void drawLine(Point p0, Point p1); void drawRectangle(int x0, int y0, int x1, int y1); + void drawRectangle(double x0, double y0, double x1, double y1); void drawRectangle(Point p0, Point p1); void drawArc(int x, int y, int r); + void drawArc(double x, double y, double r); void drawArc(Point p, int r); private: class Impl; boost::scoped_ptr<Impl> impl; };
然后在实现文件里增加 forward,这么做不会破坏二进制兼容性,因为增加 non-virtual 函数不影响现有的可执行文件。
--- old/graphics.cc 2011-03-12 15:15:20.000000000 +0800 +++ new/graphics.cc 2011-03-12 15:15:26.000000000 +0800 @@ -1,35 +1,43 @@ #include <graphics.h> class Graphics::Impl { public: void drawLine(int x0, int y0, int x1, int y1); + void drawLine(double x0, double y0, double x1, double y1); void drawLine(Point p0, Point p1); void drawRectangle(int x0, int y0, int x1, int y1); + void drawRectangle(double x0, double y0, double x1, double y1); void drawRectangle(Point p0, Point p1); void drawArc(int x, int y, int r); + void drawArc(double x, double y, double r); void drawArc(Point p, int r); }; Graphics::Graphics() : impl(new Impl) { } Graphics::~Graphics() { } void Graphics::drawLine(int x0, int y0, int x1, int y1) { impl->drawLine(x0, y0, x1, y1); } +void Graphics::drawLine(double x0, double y0, double x1, double y1) +{ + impl->drawLine(x0, y0, x1, y1); +} + void Graphics::drawLine(Point p0, Point p1) { impl->drawLine(p0, p1); }
采用 pimpl 多了一道 forward 的手续,带来的好处是可扩展性与二进制兼容性,通常是划算的。pimpl 扮演了编译器防火墙的作用。
pimpl 不仅 C++ 语言可以用,C 语言的库同样可以用,一样带来二进制兼容性的好处,比如 libevent2 里边的 struct event_base 是个 opaque pointer,客户端看不到其成员,都是通过 libevent 的函数和它打交道,这样库的版本升级比较容易做到二进制兼容。
为什么 non-virtual 函数比 virtual 函数更健壮?因为 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加载器 (loader) 会在程序启动时做决议(resolution),通过 mangled name 把可执行文件和动态库链接到一起。就像使用 Internet 域名比使用 IP 地址更能适应变化一样。
万一要跨语言怎么办?很简单,暴露 C 语言的接口。Java 有 JNI 可以调用 C 语言的代码,Python/Perl/Ruby 等等的解释器都是 C 语言编写的,使用 C 函数也不在话下。C 函数是万能的接口,C 语言是最伟大的系统编程语言。
本文只谈了使用 class 为接口,其实用 free function 有时候更好(比如 muduo/base/Timestamp.h 除了定义 class Timestamp,还定义了 muduo::timeDifference() 等 free function),这也是 C++ 比 Java 等纯面向对象语言优越的地方。留给将来再细谈吧。
参考文献
[1] Scott Meyers, 《Effective C++》 第 3 版,条款 35:考虑 virtual 函数以外的其他选择;条款 23:宁以 non-member、non-friend 替换 member 函数。
[2] Herb Sutter and Andrei Alexandrescu, 《C++ 编程规范》,条款 39:考虑将 virtual 函数做成 non-public,将 public 函数做成 non-virtual;条款 43:明智地使用 pimpl;条款 44:尽可能编写 nonmember, nonfriend 函数;条款 57:将 class 和其非成员函数接口放入同一个 namespace。
[3] 孟岩,《function/bind的救赎(上)》,《回复几个问题》中的“四个半抽象”。
[4] 陈硕,《以 boost::function 和 boost:bind 取代虚函数》,《朴实的 C++ 设计》。
本作品采用知识共享署名-非商业性使用-相同方式共享 3.0 Unported许可协议进行许可。