C++ 工程實踐:避免使用虛函數作爲庫的接口

原文: http://blog.csdn.net/Solstice/archive/2011/03/12/6244905.aspx

陳碩 (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 派生出 Graphics2

--- 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,再讓實現同時繼承這兩個 interfaces。

--- 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 函數是 Linux 下的萬能接口,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++ 設計》。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章