当前位置:编程学习 > VC++ >>

C++ 工程实践(5):避免使用虚函数作为库的接口

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

摘要:作为 C++ 动态库的作者,应当避免使用虚函数作为库的接口。这么做会给保持二进制兼容性带来很易做图烦,不得不增加很多不必要的 inte易做图ces,最终重蹈 COM 的覆辙。

本文主要讨论 Linux x86 平台,会继续举 Windows/COM 作为反面教材。

本文是上一篇《C++ 工程实践(4):二进制兼容性》的延续,在写这篇文章的时候,我原本以外大家都对“以虚函数作为接口”的害处达成共识,我就写得比较简略,看来情况不是这样,我还得展开谈一谈。

“接口”有广义和狭义之分,本文用中文“接口”表示广义的接口,即一个库的代码界面;用英文 inte易做图ce 表示狭义的接口,即只包含 virtual function 的 class,这种 class 通常没有 data member,在 Java 里有一个专门的关键字 inte易做图ce 来表示它。

C++ 程序库的作者的生存环境
假设你是一个 shared library 的维护者,你的 library 被公司另外两三个团队使用了。你发现了一个安全漏洞,或者某个会导致 crash 的 bug 需要紧急修复,那么你修复之后,能不能直接部署 library 的二进制文件?有没有破坏二进制兼容性?会不会破坏别人团队已经编译好的投入生成环境的可执行文件?是不是要易做图别的团队重新编译链接,把可执行文件也发布新版本?会不会打乱别人的 release cycle?这些都是工程开发中经常要遇到的问题。

如果你打算新写一个 C++ library,那么通常要做以下几个决策:

以什么方式发布?动态库还是静态库?(本文不考虑源代码发布这种情况,这其实和静态库类似。)
以什么方式暴露库的接口?可选的做法有:以全局(含 namespace 级别)函数为接口、以 class 的 non-virtual 成员函数为接口、以 virtual 函数为接口(inte易做图ce)。
(Java 程序员没有这么多需要考虑的,直接写 class 成员函数就行,最多考虑一下要不要给 method 或 class 标上 final。也不必考虑动态库静态库,都是 .jar 文件。)

在作出上面两个决策之前,我们考虑两个基本假设:

代码会有 bug,库也不例外。将来可能会发布 bug fixes。
会有新的功能需求。写代码不是一锤子买卖,总是会有新的需求冒出来,需要程序员往库里增加东西。这是好事情,让程序员不丢饭碗。
(如果你的代码第一次发布的时候就已经做到完美,将来不需要任何修改,那么怎么做都行,也就不必继续阅读本文。)

也就是说,在设计库的时候必须要考虑将来如何升级。

基于以上两个基本假设来做决定。第一个决定很好做,如果需要 hot fix,那么只能用动态库;否则,在分布式系统中使用静态库更容易部署,这在前文中已经谈过。(“动态库比静态库节约内存”这种优势在今天看来已不太重要。)

以下本文假定你或者你的老板选择以动态库方式发布,即发布 .so 或 .dll 文件,来看看第二个决定怎么做。(再说一句,如果你能够以静态库方式发布,后面的麻烦都不会遇到。)

第二个决定不是那么容易做,关键问题是,要选择一种可扩展的 (extensible) 接口风格,让库的升级变得更轻松。“升级”有两层意思:

对于 bug fix only 的升级,二进制库文件的替换应该兼容现有的二进制可执行文件,二进制兼容性方面的问题已经在前文谈过,这里从略。
对于新增功能的升级,应该对客户代码的友好。升级库之后,客户端使用新功能的代价应该比较小。只需要包含新的头文件(这一步都可以省略,如果新功能已经加入原有的头文件中),然后编写新代码即可。而且,不要在客户代码中留下垃圾,后文我们会谈到什么是垃圾。
在讨论虚函数接口的弊端之前,我们先看看虚函数做接口的常见用法。

虚函数作为库的接口的两大用途
虚函数为接口大致有这么两种用法:

调用,也就是库提供一个什么功能(比如绘图 Graphics),以虚函数为接口方式暴露给客户端代码。客户端代码一般不需要继承这个 inte易做图ce,而是直接调用其 member function。这么做据说是有利于接口和实现分离,我认为纯属脱了裤子放易做图。
回调,也就是事件通知,比如网络库的“连接建立”、“数据到达”、“连接断开”等等。客户端代码一般会继承这个 inte易做图ce,然后把对象实例注册到库里边,等库来回调自己。一般来说客户端不会自己去调用这些 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 调用到正确的函数。

怎么办呢?有一种危险且丑陋的做法:把新的虚函数放到 inte易做图ce 的末尾,例如:

--- 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. 通过链式继承来扩展现有 inte易做图ce,例如从 Graphics 派生出 Graphics2

--- graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800补充:软件开发 , Vc ,

CopyRight © 2022 站长资源库 编程知识问答 zzzyk.com All Rights Reserved
部分文章来自网络,