如何在C++17中使用std::variant实现类型安全的状态机

在许多应用场景中,我们需要设计一个状态机来描述对象的不同状态,例如一个订单系统可以处于「待支付」「已支付」「已发货」「已完成」等状态。传统的做法是使用枚举或字符串来表示状态,然后在代码中使用大量的 if/elseswitch 来处理不同状态下的行为。这样不仅不够类型安全,也不利于维护和扩展。

C++17 引入了 std::variant,它提供了一个类型安全、无运行时开销的联合体。我们可以利用 std::variant 来实现一个可扩展的状态机,每一种状态都对应一个独立的类型,状态转移通过 std::visit 或模式匹配完成。下面给出一个完整的示例,演示如何使用 std::variant 来实现一个订单状态机,并说明其优点。

1. 定义状态类型

首先,为每一种状态创建一个空结构体,作为类型标签。若需要在状态中携带数据,可以在结构体中添加成员。

struct PendingPayment {};          // 待支付
struct Paid {};                    // 已支付
struct Shipped { std::string tracking_number; };  // 已发货
struct Completed {};               // 已完成
  • PendingPaymentPaidCompleted 是不携带数据的标记类型。
  • Shipped 包含一个 tracking_number,演示携带状态数据的用法。

2. 定义订单类

订单类包含一个 std::variant 成员,表示当前状态,并提供状态转移的接口。

#include <variant>
#include <string>
#include <iostream>
#include <stdexcept>

class Order {
public:
    using State = std::variant<PendingPayment, Paid, Shipped, Completed>;

    Order() : state_(PendingPayment{}) {}   // 初始状态为 PendingPayment

    // 访问当前状态
    const State& state() const { return state_; }

    // 触发支付事件
    void pay() {
        std::visit([this](auto& s) { this->handlePay(s); }, state_);
    }

    // 触发发货事件
    void ship(const std::string& tracking) {
        std::visit([this, &tracking](auto& s) { this->handleShip(s, tracking); }, state_);
    }

    // 触发完成事件
    void complete() {
        std::visit([this](auto& s) { this->handleComplete(s); }, state_);
    }

private:
    State state_;

    // 事件处理函数,使用重载实现不同状态的逻辑
    void handlePay(PendingPayment&) {
        state_ = Paid{};
        std::cout << "Order paid.\n";
    }
    void handlePay(Paid&) {
        throw std::logic_error("Order is already paid.");
    }
    void handlePay(Shipped&) {
        throw std::logic_error("Cannot pay after shipping.");
    }
    void handlePay(Completed&) {
        throw std::logic_error("Cannot pay after completion.");
    }

    void handleShip(Paid& s, const std::string& tracking) {
        state_ = Shipped{tracking};
        std::cout << "Order shipped, tracking: " << tracking << ".\n";
    }
    void handleShip(PendingPayment&) {
        throw std::logic_error("Cannot ship before payment.");
    }
    void handleShip(Shipped&) {
        throw std::logic_error("Order already shipped.");
    }
    void handleShip(Completed&) {
        throw std::logic_error("Cannot ship after completion.");
    }

    void handleComplete(Shipped& s) {
        state_ = Completed{};
        std::cout << "Order completed.\n";
    }
    void handleComplete(PendingPayment&) {
        throw std::logic_error("Cannot complete before shipping.");
    }
    void handleComplete(Paid&) {
        throw std::logic_error("Cannot complete before shipping.");
    }
    void handleComplete(Completed&) {
        throw std::logic_error("Order already completed.");
    }
};

关键点说明

  1. 状态存储Statestd::variant,只包含合法的状态类型。编译器会检查我们是否在任何地方把非法的类型放进去。
  2. 事件分派:每个事件(payshipcomplete)通过 std::visit 调用对应的处理函数。处理函数通过函数重载实现对不同状态的特化逻辑。
  3. 错误处理:当事件不合法时抛出异常,保证在错误使用时能得到明确提示。

3. 使用示例

int main() {
    Order o;

    try {
        o.pay();                // OK
        o.ship("TRK12345");     // OK
        o.complete();           // OK

        // o.pay();            // 会抛异常:Cannot pay after completion.
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
    }

    return 0;
}

运行结果:

Order paid.
Order shipped, tracking: TRK12345.
Order completed.

4. 优点与扩展

  1. 类型安全std::variant 确保状态只能是预定义的几种类型,任何非法状态都会在编译期被捕获。
  2. 可维护性:状态转移逻辑集中在对应的处理函数中,易于阅读和修改。添加新状态只需要定义新类型并实现相应的处理函数。
  3. 无运行时开销std::variant 是无运行时开销的联合体,类似于枚举,保持高效。
  4. 状态携带数据:如果状态需要携带数据,只需在对应结构体中添加成员即可。std::visit 可以轻松访问这些成员。

在更复杂的系统中,可以结合模板元编程或状态机库(如 Boost.Statechart)进一步提升表达力和可复用性。但即便是最简单的 std::variant 方案,也已经能够提供比传统枚举更安全、更易维护的状态机实现。

发表评论