[译]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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索