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