强制对象状态不变的解决方案

Solution to enforce invariant for object state

我遇到一个问题,可能有一些我不知道的标准解决方案或设计模式。 假设我有一个像这样的 Rectangle class:

class Rectangle {
public:
    void setShape(float top, float left, float width, float height);
    void setCenter(float cx, float cy);
private:
    float top, left, width, height, center_x, center_y;
}

现在,当 Rectangle 的用户调用 setShape 时,该方法也会设置中心 如果用户调用 setCenter,该方法将相应地修改 topleft,使矩形的中心与其左上角一致。

但是在比较复杂的设置中,设置相关的参数很容易引入bug setter 中的字段而不修改其余字段,以便整个对象保持一致且有意义。

是否有通用的解决方案/设计模式以某种方式强制执行,无论是在编译时还是在运行时,对象在每个 setter 完成后都将满足一些不变量?

谢谢

正如评论中指出的那样,删除冗余成员肯定有助于防止违反不变性的问题,但是在您发布的代码中仍然有值得检查的隐式不变量:widthheight 是正数,例如。

无论如何,如何系统地处理不变量检查无论如何都是有趣的。理想情况下,我们应该具备以下所有条件:

  • A compile-time 开关从发布版本中去除不变检查
  • 约定,例如bool test_invariant() const 成员方法。任何实现它的类型都被理解为具有可检查的不变量。
  • 一个free-floating函数,默认为那个成员,这样任意类型都可以opted-in,类似于std::begin()
  • 一个函数,例如check_invariant(const T&) 如果传递的 object 具有失败的不变量,则停止程序。
  • 能够在任何有意义的地方调用 check_invariant(something)(例如在单元测试中)
  • 在范围退出时调用 check_invariant() 的 RAII 包装器,以便正确处理异常和早期 returns。

听起来很多,但大部分都可以在 header 中隐藏起来,在适当的代码中留下相当干净的 API。

借助一些 C++20 功能,我们可以这样进行:

// invariant.h

#include <concepts>
#include <exception>
#include <iostream>
#include <source_location>
#include <string_view>

#ifdef _MSC_VER
  #include <intrin.h>  // for __debugbreak()
#endif

// Default to NDEBUG-driven if not explicitely set.
#ifndef MY_PROJECT_CHECK_INVARIANTS
  #ifdef NDEBUG 
    #define MY_PROJECT_CHECK_INVARIANTS false
  #else
    #define MY_PROJECT_CHECK_INVARIANTS true
  #endif
#endif

// Compile-time switch to enable/disable invariant checks
constexpr bool enable_invariant_checks = MY_PROJECT_CHECK_INVARIANTS;

// optional: Concepts, to get cleaner errors 
template<typename T>
concept HasInvariantMethod = requires(const T& x) {
  {x.test_invariant()} -> std::convertible_to<bool>;
};

template<typename T>
concept HasInvariant = requires(const T& x) {
  {test_invariant(x)} -> std::convertible_to<bool>;
};
 
// Should be overloaded for types we can't add a method to.
template<HasInvariantMethod T>
[[nodiscard]] constexpr bool test_invariant(const T& obj) {
  return obj.test_invariant();
}

// Performs invariant check if they are enabled, becomes a no-op otherwise.
template<HasInvariant T>
constexpr void check_invariant(
    const T& obj, 
    std::string_view msg = {},
    std::source_location loc = std::source_location::current()) {
  if constexpr(enable_invariant_checks) {
    if(!test_invariant(obj)) {
      std::cerr << "broken invariant: "
              << loc.file_name() << "("
              << loc.line() << ":"
              << loc.column() << ") `"
              << loc.function_name() << "`: "
              << msg << '\n';

      // break into the ddebugger if available
      #ifdef _MSC_VER
       __debugbreak();
      #else
        // etc...
      #endif
      // Invariant failures are inherently unrecoverable.
      std::terminate();
    }
  }
}

// RAII-driven invariant checks.
// This ensures early returns and thrown exceptions are handled.
template<typename T>
struct [[nodiscard]] scoped_invariant_check {
  constexpr scoped_invariant_check(const T& obj, std::source_location loc = std::source_location::current()) : obj_(obj), loc_(std::move(loc)) {
      // Checking invariants upon entering a scope is technically
      // redundant, but there's no harm in doing so.
      check_invariant(obj_, "entering scope", loc_);
  }

  constexpr ~scoped_invariant_check() {
      check_invariant(obj_, "exiting scope", std::move(loc_));
  }

  const T& obj_;
  std::source_location loc_;
};

用法示例:

#include "invariant.h"

class Rectangle {
public:
    void setShape(float top, float left, float width, float height) {
      scoped_invariant_check check(*this);
      // ...
      top_ = top;
      left_ = left;

      // BUG! These could go negative
      width_ = width;
      height_ = height;
    }

    void setCenter(float cx, float cy) {
      scoped_invariant_check check(*this);
      // ...
      
    }

    bool test_invariant() const {
        return 
          width_ >= 0.0f &&
          height_ >= 0.0f;
    }
private:
    float top_ = 0.0f, left_ = 0.0f , width_ = 0.0f, height_ = 0.0f;
};

int main() {
  Rectangle r;

  r.setShape(1,1, -1, 12); // Bam!
}

godbolt.

上直播

或者,如果您想使事情简单化,您可以将大部分内容近似为 good-old assert():

#include <cassert>

class Rectangle {
public:
   void setShape(float top, float left, float width, float height) {
     // ...

     assert(test_invariant());
   }

   void setCenter(float cx, float cy) {
     // ...

     assert(test_invariant());
   }

private:
   bool test_invariant() const {
       return ...;
   }

   float top, left, width, height;
};