Decomposing an expression
One of the many selling points of catch
(and catch2
) compared to other test frameworks was how natural it felt using it.
No need for a ton of assert macro for testing every possible relation operator like assert_equal
, assert_less
, assert_less_equal
, and so on. Mainly just use one assert
(REQUIRE
in case of catch
), and the framework will decompose the operation, and even show all necessary pieces of information in case of failure.
The trick used behind the scenes was, the first time I read it, so mindblowing, that I needed to reimplement it myself. As lately I had a couple of places where I wanted to decompose an expression, I wrote this as a future reference.
Spoiler alert: it uses an overloaded operator<
(or another comparison operator).
A minimal implementation
#include <string>
#include <cassert>
enum class op {
none = 0, // none
eq, // ==
neq, // !=
le, // <=
l, // <
ge, // >=
g, // >
};
enum class res {
none = 0, // unknown
True,
False,
};
struct info_t {
const char* expr;
const char* file;
int line;
const char* func;
op opt_op;
res result;
std::string opt_lhs;
std::string opt_rhs;
};
template <class A>
struct check_t {
A a;
info_t& i;
template <class B>
info_t operator==(B&& b) && {
i.result = (a==b) ? res::True : res::False;
i.opt_lhs = std::to_string(a);
i.opt_rhs = std::to_string(b);
i.opt_op = op::eq;
return std::move(i);
}
// add other comparison operators
};
struct destruct_t {
info_t i;
template <class A>
check_t<A> operator<(A&& a) && {
return check_t<A>{std::forward<A>(a), i};
}
};
#define DECOMPOSE_CMP_OP(expr) destruct_t{info_t{#expr, __FILE__, __LINE__, __FUNCTION__, op::none, res::none}} < expr
int main() {
info_t i = DECOMPOSE_CMP_OP(1 + 1 == 3);
assert(i.line == __LINE__ -1); // as DECOMPOSE_CMP_OP is one line before
assert(i.file == __FILE__);
assert(i.func == __FUNCTION__);
assert(i.opt_rhs == "3");
assert(i.opt_lhs == "2");
assert(i.opt_op == op::eq);
assert(i.result == res::False);
}
A more detailed explanation:
info_t
, thanks to the macro, has the whole expression as textual representation (similarly to what assert
does in many implementations), and through destruct_t::operator<
forwards the first evaluated argument of the boolean expression to check_t
. check_t
implements all comparison operators in order to capture the second element, and save the decomposed expression in info_t
.
It seems too simple, but it works!
Expanding the macro by hand
info_t i = DECOMPOSE_CMP_OP(1 + 1 == 3);
// expand DECOMPOSE_CMP_OP
info_t i = destruct_t{info_t{"1 + 1 == 3", __FILE__, __LINE__, __FUNCTION__, op::none, res::none}} < 1 + 1 == 3;
// add parenthesis to clarify order of evaluation
info_t i = (destruct_t{info_t{"1 + 1 == 3", __FILE__, __LINE__, __FUNCTION__, op::none, res::none}} < (1 + 1)) == 3;
// simplify 1+1
info_t i = (destruct_t{info_t{"1 + 1 == 3", __FILE__, __LINE__, __FUNCTION__, op::none, res::none}} < 2) == 3;
// apply destruct_t constructor
info_t i = (instanceof(destruct_t) < 2) == 3;
// apply destruct_t::operator<
info_t i = instanceof(check_t<int>) == 3;
// apply check_t<int>::operator==
info_t i = instanceof(info_t);
Possible use-cases
While it seems that destructuring an assertion is not that useful, except for a testing framework, it can be used for regular code too, especially for finding errors.
An example is validating parameters, and generating descriptive messages:
#define VERIFY_PARAM(expr) { info_t dec = DECOMPOSE_CMP_OP(expr); if(dec.result == res::False){ throw 42;}}
int foo(int i){
VERIFY_PARAM(i > 0);
// ...
}
It can also be used for creating a better assert
macro or to enhance a logging system.
Possible improvements
For a testing assertion or logging framework, some implementation choices are suboptimal. For example, we only want to destructure the expression in case of an error. In particular, allocating those strings for every test and then throwing them away is not an ideal design choice. It also currently works only for integral types, because of std::to_string
.
Also info_t
assumes that the result is somehow boolean, while both check_t
and destruct_t
do return something much bigger.
Some possible improvements compared to the minimal implementation are thus
-
compute the result only if needed
-
for an assertion (or generally, if the buffer is used before another call to
DECOMPOSE_CMP_OP
) a thread-local buffer (with some preallocated space, but beware ifDECOMPOSE_CMP_OP
is used recursively ) might be better than a local one -
avoid using
std::to_string
, use a stringify function as a customization point for user-provided types and with a defined fallback behavior for unknown types
Do you want to share your opinion? Or is there an error, some parts that are not clear enough?
You can contact me anytime.