Home > OS >  Solution to enforce invariant for object state
Solution to enforce invariant for object state

Time:11-10

I have a problem that probably has some standard solution or a design pattern that I'm unaware of. Suppose I have a Rectangle class like this:

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;
}

Now, when a user of Rectangle calls setShape, the method will also set the center and if the user calls setCenter, the method will modify top and left accordingly, such that the center of the rectangle is consistent with it's top-left corner.

However, in a more complicated setting, it is easy to introduce a bug by setting the relevant fields in a setter and not modifying the remaining fields such that the entire object remains consistent and makes sense.

Is there a general solution / design pattern to somehow enforce, either at compile time or at runtime, that an object will satisfy some invariant after each setter is finished?

Thanks

CodePudding user response:

As pointed out in the comments, removing redundant members can definitely help prevent invariant violation issues, but there are still implicit invariants worth checking in your example: width and height being positive numbers, for example.

In any case, how to systematically handle invariant checking is interesting regardless. Ideally we should have all the following:

  • A compile-time switch to strip invariant checks from release builds
  • A convention e.g. bool test_invariant() const member method. Any type implementing this is understood to have a checkable invariant.
  • A free-floating function that defaults to that member so that arbitrary types can be opted-in, similar to std::begin()
  • A function e.g. check_invariant(const T&) that stops the program if the passed object has a failed invariant.
  • The ability to call check_invariant(something) anywhere it makes sense (e.g. in the middle of unit tests)
  • A RAII wrapper to invoke check_invariant() on scope exits so that exceptions and early returns are handled correctly.

It sounds like a lot, but most of this can be squirreled away in a header, leaving a fairly clean API within the code proper.

With the help of a few C 20 features, we could go about it like this:

// 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_;
};

Usage example:

#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!
}

Play with it live on godbolt.

Alternatively, if you want to keep things simple, you can approximate most of that with 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;
};
  • Related