C++ 工程實踐(5):避免使用虛函數作為庫的介面

來源:互聯網
上載者:User

陳碩 (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 把可執行檔和動態庫連結到一起。就像使用 網際網路網域名稱比使用 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許可協議進行許可。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.