The C++ logo, by Jeremy Kratz, licensed under CC0 1.0 Universal

Decomposing an expression


3 - 4 minutes read, 787 words
Categories: c++
Keywords: c++ comparison reflection

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 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 alls 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 throw them away is not a sensible 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 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 if DECOMPOSE_CMP_OP is used recursively ) might be better than a local one

  • avoid std::to_string, use a stringify function as a customization point for user-provided types and with a defined fallback behavior for unknown types