[譯]C++ 中清晰明瞭的狀態機代碼

C++ 中清晰明瞭的狀態機代碼

這是 Valentin Tolmer 的特邀文章。 Valetin 是谷歌的一名軟件工程師,他試圖提升他周圍的代碼質量。他年輕時就受到模板編程的影響而且如今只致力於元編程。你能夠在 GitHub 找到他的一些工做內容,特別是本文所涉及的 ProtEnc 庫。前端

你曾經遇到過這種註釋嗎?android

// 重要:在調用 SetUp() 以前請不要調用該函數!
複製代碼

或者作這樣的檢查:ios

if  (my_field_.empty())  abort();
複製代碼

這些(註釋中提出的狀態檢查要求)都是咱們的代碼必須遵照的協議的通病。有些時候,你正在遵照的一個明確的協議也會有狀態檢查的要求,例如在 SSL 握手或者其餘業務邏輯實現中。或者可能在你的代碼中有一個明確狀態轉換的狀態機,該狀態機每次都須要根據可能的轉換列表作轉換狀態檢查。c++

讓咱們看看咱們如何清晰明瞭地處理這種方案。git

例如:創建一個 HTTP 鏈接

咱們今天的示例是構建一個 HTTP 鏈接。爲了大大簡化,咱們只說咱們的鏈接請求至少包含一個 header(也許會更多),有且只有一個 body,而且這些 header 必須在 body 以前被指定出來(例如由於性能緣由,咱們只寫入一個追加的數據結構)。github

備註:雖然這個特定的問題能夠經過給構造函數傳遞正確的參數來解決,我不想使這個協議過於複雜。你將看到擴展它是多麼的容易。express

這是第一次實現:編程

class HttpConnectionBuilder {
 public:
  void add_header(std::string header) {
    headers_.emplace_back(std::move(header);
  }
  // 重要: 至少調用一次 add_header 以後才能被調用
  void  add_body(std::string  body)  {
    body_  =  std::move(body);
  }
  // 重要: 只能調用 add_body 以後才能被調用
  // 消費對象
  HttpConnection build()  &&  {
    return  {std::move(headers_),  std::move(body_)};
  }
 private:
  std::vector<std::string>  headers_;
  std::string  body_;
};
複製代碼

直到如今,這個例子至關的簡單,可是它依賴於用戶不要作錯事情:若是他們沒有提早閱讀過文檔,沒有什麼能夠阻止他們在 body 以後添加另外的 header。若是將其放入到一個 1000 行的文件中,你很快就會發現這有多糟糕。更糟糕的是,沒有檢查類是否被正確的使用,因此,查看類是否被誤用的惟一方法是觀察是否有意料以外的效果!若是它致使了內存損壞,那麼祝您調試順利。後端

其實咱們能夠作的更好……安全

使用動態枚舉

一般狀況下,該協議能夠用一個有限狀態機來表示:該狀態機開始於咱們沒有添加任何的 header 的狀態(START 狀態),該狀態下只有一個添加 header 的選項。而後進入至少添加一個 header (HEADER 狀態),該狀態下既能夠添加另外的 header 來保持該狀態,也能夠添加一個 body 而進入到 BODY 狀態。只有在 BODY 這個狀態下咱們能夠調用 build,讓咱們進入到最終狀態。

typestates state machine

因此,讓咱們將這些想法寫到咱們的類中!

enum  BuilderState  {
  START,
  HEADER,
  BODY
};
class HttpConnectionBuilder {
  void add_header(std::string header) {
    assert(state_  ==  START  ||  state_  ==  HEADER);
    headers_.emplace_back(std::move(header));
    state_  =  HEADER;
  }
  ...
 private:
  BuilderState state_;
  ...
};
複製代碼

其餘的函數也是這樣。這已經很好了:咱們有一個肯定的狀態告訴咱們哪一種轉換是可能的,而且咱們檢查了它。固然了,你有針對你的代碼的周密的測試用例,對嗎?若是你的測試對代碼有足夠的覆蓋率,那麼你將可以在測試的時候捕獲任何違規的操做。你也能夠在生產環境中啓用這些檢查,以確保不會偏離該協議(受控崩潰總比內存損壞要強),可是你必須對增長的檢查付出代價。

使用類型狀態(typestates)

咱們怎麼才能更快地、100% 準確地捕獲到這些錯誤呢?那就讓編譯器來作這些工做!下面我將介紹類型狀態(typestates)的概念。

大體說來,類型狀態(typestates)是將對象的狀態編碼爲其自己的類型。有些語言經過爲每一個狀態實現一個單獨的類來實現(好比 HttpBuilderWithoutHeaderHttpBuilderWithBody 等等),但這在 C++ 中將會變得很是的冗長:咱們不得不聲明構造函數、刪除拷貝函數、將一個對象轉換成另一個對象…… 而且它很快就會過時。

可是 C++ 還有其餘的妙招:模板!咱們能夠在 enum 中對狀態進行編碼,而且使用這個 enum 將構造器模板化。這就獲得了以下的代碼:

template  <BuilderState  state>
class HttpConnectionBuilder {
  HttpConnectionBuilder<HEADER> 
  add_header(std::string  header)  &&  {
    static_assert(state  ==  START  ||  state  ==  HEADER, 
      "add_header can only be called from START or HEADER state");
    headers_.emplace_back(std::move(header));
    return  {std::move(*this)};
  }
  ...
};
複製代碼

這裏咱們靜態地檢查對象是否處於正確的狀態,無效代碼甚至沒法編譯!而且咱們還能夠獲得了一個至關清晰的錯誤信息。每次咱們建立與目標狀態相對應的新對象時,咱們也銷燬了與以前狀態對應的對象:你在類型爲 HttpConnectionBuilder<START>的對象上調用 add_header,可是你將獲得一個 HttpConnectionBuilder<HEADER> 類型的返回值。這就是類型狀態(typestates)的核心思想。

注意:這個方法只能在右值引用(r-values)中調用(std::move,就是函數聲明行末尾的 && 的做用)。爲何要這樣呢?它強制性地破壞了前一個狀態,所以只能獲得一個相關的狀態。能夠將其看作 unique_ptr:你不想複製一個內部的構件並得到無效的狀態。就像 unique_ptr 只有一個全部者同樣,類型狀態(typestates)也必須只有一個狀態。

有了這個,你就能夠這樣寫:

auto connection  =  GetConnectionBuilder()
  .add_header("first header")
  .add_header("second header")
  .add_body("body")
  .build();
複製代碼

任何對協議的偏離都會致使編譯失敗。

這有幾個不管如何都要遵照的規則:

  • 你全部的函數必須使用右值引用的對象(好比 *this 必須是一個右值引用,在末尾要要有 &&)。
  • 你可能須要禁用拷貝函數,除非跳轉到協議中間狀態的時候是有意義的(畢竟這就是咱們有右值引用的緣由)。
  • 你有必要聲明你的構造函數爲私有,並添加一個工廠(factory)函數來確保人們不會建立一個無開始狀態的對象。
  • 你須要將移動構造函數添加爲友元並實現到另一種狀態,沒有這種狀態,你就能夠隨意地將對象從一個狀態轉移到另一種狀態。
  • 你須要肯定你已經在每一個函數中添加了檢查。

總而言之,從頭開始正確的實現這些是有一點兒棘手的,而且在天然增加中,你頗有可能不想要15種不一樣的自制類型狀態(typestates)實現。若是有一個框架能夠輕鬆且安全地聲明這些類型狀態就行了!

ProtEnc 庫

這就是 ProtEnc(protocol encoder 的簡稱)發揮做用的地方。有了數量驚人的模板,該庫容許輕鬆的聲明實現 typestate 檢查的類。要使用它,須要你的(未檢查的)協議實現,這是咱們用全部「重要的」註釋實現的第一個類。

咱們將給這個類增長一個與其有相同的接口可是增長了類型檢查的包裝類。該包裝類將在它的類型中包含一些諸如可能的初始化狀態、轉換和最終狀態。每一個包裝類函數只是簡單的檢查轉換是否可行,而後完美的轉發調用給下一個對象。全部的這些都不包括指針的間接尋址、運行時組件或者內存分配,因此它徹底自由的!

那麼,咱們怎麼聲明這個包裝類呢?首先,咱們不得不定義一個有限狀態機。這包括三個部分:初始狀態、轉換和最終狀態或者轉換。初始狀態的列表只是咱們的枚舉類型的列表,就像下邊這樣的:

using  MyInitialStates  =  InitialStates<START>;
複製代碼

對於轉換,咱們須要初始化狀態、最終狀態和執行狀態轉換的函數:

using  MyTransitions  =  Transitions<
  Transition<START,  HEADERS,  &HttpConnectionBuilder::add_header>,
  Transition<HEADERS,  HEADERS,  &HttpConnectionBuilder::add_header>,
  Transition<HEADERS,  BODY,  &HttpConnectionBuilder::add_body>>;
複製代碼

對於最終的轉換,咱們也須要一個狀態和函數:

using  MyFinalTransitions  =  FinalTransitions<
  FinalTransition<BODY,  &HttpConnectionBuilder::build>>;
複製代碼

這個額外的 "FinalTransitions" 是由於咱們可能會定義多個 "FinalTransition"。

如今咱們能夠聲明咱們的包裝類的類型了。一些不可避免的模板被宏定義隱藏起來,但它主要是基類的構造或者元的聲明。

PROTENC\_DECLARE\_WRAPPER(HttpConnectionBuilderWrapper,  HttpConnectionBuilder,  BuilderState,  MyInitialStates,  MyTransitions,  MyFinalTransitions);
複製代碼

這是展開的一個做用域(一個類),咱們能夠在其中轉發咱們的函數:

PROTENC\_DECLARE\_TRANSITION(add_header);
PROTENC\_DECLARE\_TRANSITION(add_body);
PROTENC\_DECLARE\_FINAL_TRANSITION(build);
複製代碼

而後是關閉做用域。

PROTENC\_END\_WRAPPER;
複製代碼

(那只是一個右括號,但你不想要不匹配的括號,是嗎?)

經過這個簡單但可擴展的設置,你就能夠像使用上一步中的包裝器同樣使用它啦,而且全部的操做都會被檢查。🙂

auto connection  =  HttpConnectionBuilderWrapper<START>{}
  .add_header("first header")
  .add_header("second header")
  .add_body("body")
  .build();
複製代碼

試圖在錯誤的順序下調用函數將致使編譯錯誤。別擔憂,精心的設計保證了第一個錯誤信息是可讀的😉。例如,移除 .add_body("body") 行,你將獲得如下錯誤:

In file included from example/http_connection.cc:6:

src/protenc.h:  In  instantiation of  ‘struct prot_enc::internal::return\_of\_final\_transition\_t<prot_enc::internal::NotFound,  HTTPConnectionBuilder>’:
src/protenc.h:273:15:     required by  ...
example/http_connection.cc:174:42:     required from here
src/protenc.h:257:17:  error:  static  assertion failed:  Final  transition not  found
   static_assert(!std::is\_same\_v<T,  NotFound>,  "Final transition not found");
複製代碼

只要確保包裝類只能從包裝器構造,就能夠保證整個代碼庫的正確運行!

若是您的狀態機是以另外一種形式編碼的(或者若是它變得太大了),那麼生成描述它的代碼就很簡單了,由於全部的轉換和初始狀態都是以一種容易讀/寫的格式彙集在一塊兒的。

完整的代碼示例能夠在 GitHub 找到。請注意該代碼如今不能使用 Clang 由於 bug #35655

你將也喜歡

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索