zoukankan      html  css  js  c++  java
  • 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 的升级,二进制库文件的替换应该兼容现有的二进制可执行文件,二进制兼容性方面的问题已经在前文谈过,这里从略。
    • 对于新增功能的升级,应该对客户代码的友好。升级库之后,客户端使用新功能的代价应该比较小。只需要包含新的头文件(这一步都可以省略,如果新功能已经加入原有的头文件中),然后编写新代码即可。而且,不要在客户代码中留下垃圾,后文我们会谈到什么是垃圾。

    在讨论虚函数接口的弊端之前,我们先看看虚函数做接口的常见用法。

    虚函数作为库的接口的两大用途

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

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

  • 相关阅读:
    [转载]安装SQL Server 2008 R2遇到“...Setup has stopped working.”
    WPF验证错误显示
    说一下我对Mvvm模式的理解
    [转载]C#深拷贝的方法
    Windows Phone 开发(一):入门指南 — 安装开发环境:Windows Phone SDK
    DateTime.ToString() Patterns
    Log4net 根据日志类别保存到不同的文件,并按照日期生成不同文件名称
    使用Visual Studio 2010进行UI自动化测试
    WPF触发器之数据触发器(A)
    Getting The imported project "C:\Program Files\MSBuild\Microsoft\Silverlight for Phone\v4.0\Microsoft.Silverlight..Overrides.targets" was not found
  • 原文地址:https://www.cnblogs.com/Solstice/p/1982563.html
Copyright © 2011-2022 走看看