Skip to main content

Expression templates and comparators

A check such as t.expect (mt::eq (a, 2)) does not pass a bool to expect(). The function comparator eq() returns an expression object, which holds the two operands and the result of the comparison, so that a failed check can report 1 == 2, and not only that it failed. This page describes these objects: their types, how they are built, how they store their operands, and how the logical functions combine them.

The expression templates are declared in detail.h, with their definitions in inlines/detail-inlines.h; the function comparators are declared in function-comparators.h and exceptions.h, and defined in their inlines; the concepts that constrain them are in extension.h.

The expression types​

All the expression types are in the detail namespace; most are templates:

TemplateCreated byResultReported as
compare_op<Lhs_T, Rhs_T, Compare_T>eq(), ne(), gt(), ge(), lt(), le()Compare_T::apply (lhs, rhs)1 == 2
approx_op<Lhs_T, Rhs_T, Epsilon_T>approx()the distance is at most the tolerance1.0 ~ (1.2 +/- 0.1)
and_op<Lhs_T, Rhs_T>all_of()both operands are true(1 == 1 and 1 < 0)
or_op<Lhs_T, Rhs_T>any_of()at least one operand is true(1 == 2 or 1 < 0)
not_op<T>negate()the operand is falsenot (1 == 1)
predicate_op<T, Predicate_T>the applications, through extensionPredicate_T::apply (operand)even 7
throws_op<Exception_T>, nothrow_opthrows(), nothrow()the callable threw, or did notthrows, but nothing was thrown

The reports in the last column are those of the failed checks, as written by the TAP reporter in the condition: line.

Every expression type:

  • derives from the empty struct detail::op, which marks it as a framework type;
  • computes its result once, in its constructor, and stores it as a bool;
  • converts to bool through a constexpr conversion operator, which returns the stored result;
  • gives access to its operands, so that the formatter can write them.

The marker and the concepts​

The base struct only defines a type alias, which the concepts test:

extension.h (simplified)
namespace detail
{
struct op
{
using op_type = op;
};
}

namespace extension
{
template <class T>
concept is_op
= requires { requires std::same_as<typename T::op_type, detail::op>; };
}

Since the concept tests the alias, not the base class, a type can also declare the alias directly, without deriving from op.

The checks accept an expression type, or any value that can be used as a condition, except the types that are likely mistakes, such as arrays, function pointers, and char pointers, which would test an address, and the wrappers of a bool (std::optional<bool> and the smart pointers to bool), whose condition tests the wrapper, not the value that a reader assumes:

extension.h (simplified)
template <class T>
concept checkable
= is_op<T>
or (not detail::suspicious_condition<T>
and not detail::ambiguous_condition<T>
and requires (const T& t) { t ? true : false; });

The condition of the conditional operator, which cannot be overloaded, tests the contextual conversion to bool, as the condition of an if statement does; it accepts the types with an explicit operator bool, such as std::optional and std::unique_ptr, but not the scoped enumerations.

The two concepts that exclude the types, suspicious_condition and ambiguous_condition, are in the detail namespace, not in extension, since an application that writes an extension needs only checkable. So is legacy_checkable, which selects the deprecated overloads of expect() and require() for the types that suspicious_condition excluded, and which is defined only when the deprecated functionality is enabled.

The base templates​

The binary expressions derive from binary_op, and the unary ones from unary_op. The base stores the operands and the result:

detail.h (simplified)
template <class Lhs_T, class Rhs_T>
struct binary_op : op
{
template <class L, class R>
constexpr binary_op (L&& lhs, R&& rhs, bool value)
: lhs_{ std::forward<L> (lhs) }, rhs_{ std::forward<R> (rhs) },
value_{ value }
{
}

[[nodiscard]] constexpr operator bool () const { return value_; }

[[nodiscard]] constexpr decltype (auto) lhs (void) const;
[[nodiscard]] constexpr decltype (auto) rhs (void) const;

private:
Lhs_T lhs_;
Rhs_T rhs_;
const bool value_{};
};

The result is passed to the base constructor already computed; each derived template computes it from the constructor arguments. The members that hold the operands are not const, so that an expression can be moved into an enclosing one (see The logical functions).

For eq (a, b) with two int operands, the object holds two int and a bool; on a 64-bit host it occupies 12 bytes.

Comparisons and tags​

compare_op implements the six comparisons; the comparison itself is defined by a tag, an empty struct with a static symbol, written in the report between the operands, and a static apply(), which compares them:

detail.h and inlines/detail-inlines.h (simplified)
struct equal_to
{
static constexpr const char* symbol = " == ";

template <class Lhs_T, class Rhs_T>
[[nodiscard, gnu::always_inline]] static constexpr bool
apply (const Lhs_T& lhs, const Rhs_T& rhs)
{
using std::operator==;
if constexpr (sign_mixed_integers<Lhs_T, Rhs_T>)
{
return std::cmp_equal (as_comparison_integer (lhs),
as_comparison_integer (rhs));
}
else
{
return lhs == rhs;
}
}
};

Two details of apply() matter:

  • Integers of different signedness are compared by value, with std::cmp_equal() (and std::cmp_less() and the others in the other tags), so eq (-1, 1u) is false; the operator of the language would convert -1 to a large unsigned value. The character types take part as the integers of the same size and signedness, which comparison_integer_t selects (the signedness of char and wchar_t is that of the platform), and as_comparison_integer() converts to; without this, the built-in comparison of a signed char with an unsigned, or of a char32_t with an int, would convert the signed operand to the unsigned type, as for the integers. Only bool is excluded from this rule.
  • The using-declaration makes the operators of the standard library visible to unqualified lookup, together with those found by argument-dependent lookup, and hides any operator== of the enclosing namespaces of the framework.

The other tags are not_equal_to, greater, greater_equal, less, and less_equal, with the same structure.

The constructor of compare_op computes the result before it forwards the operands to the base, since the operands may be moved there:

inlines/detail-inlines.h (simplified)
template <class Lhs_T, class Rhs_T, class Compare_T>
template <class L, class R>
requires extension::compare_tag<Compare_T, unwrapped_t<L>, unwrapped_t<R>>
constexpr compare_op<Lhs_T, Rhs_T, Compare_T>::compare_op (L&& lhs, R&& rhs,
Compare_T)
: binary_op<Lhs_T, Rhs_T>{
detail::as_operand (std::forward<L> (lhs)),
detail::as_operand (std::forward<R> (rhs)),
Compare_T::apply (detail::unwrap (lhs), detail::unwrap (rhs)) }
{
}

The arguments of the base constructor are all evaluated before the base is entered, so apply() sees the operands before any of them is moved from. The tag argument is used only to deduce Compare_T; the tag stores nothing, so a parameter of the comparison, such as a tolerance, must be an operand.

From eq() to compare_op​

eq() forwards its operands to the constructor, with the tag:

function-comparators.h and its inlines (simplified)
template <class Lhs_T, class Rhs_T>
requires detail::comparable_operands<detail::comparison::equal, Lhs_T,
Rhs_T>
[[nodiscard]] constexpr auto
eq (Lhs_T&& lhs, Rhs_T&& rhs)
{
return detail::compare_op{ detail::as_operand (std::forward<Lhs_T> (lhs)),
detail::as_operand (std::forward<Rhs_T> (rhs)),
detail::equal_to{} };
}

The template arguments of compare_op are not written; they are deduced from the constructor arguments by a deduction guide, which selects how each operand is stored (see How the operands are stored):

detail.h
template <class Lhs_T, class Rhs_T, class Compare_T>
compare_op (Lhs_T&&, Rhs_T&&, Compare_T)
-> compare_op<stored_operand_t<decltype (detail::as_operand (
std::declval<Lhs_T> ()))>,
stored_operand_t<decltype (detail::as_operand (
std::declval<Rhs_T> ()))>,
Compare_T>;

The operands that change type​

as_operand() converts the operands that the operators of the language would compare by address:

  • an array of a character type (char, wchar_t, char8_t, char16_t, or char32_t), including a string literal, becomes a basic_c_string of that type, bounded by the size of the array; c_string is the alias for char;
  • a pointer to a character type becomes a basic_c_string bounded by the null terminator (the concept character_pointer); a null pointer is equal only to another null pointer, and is less than any string;
  • any other array becomes an array_operand, compared element by element with equal_to::apply(), so the elements follow the same rules as the single operands, including the comparison of integers of different signedness by value;
  • every other operand is forwarded unchanged.

The volatile characters are not strings, since a basic_c_string cannot refer to them: an array of them becomes an array_operand, like the other arrays, and a pointer to them is forwarded, and compared by address, like the other pointers. The qualifiers of an array are those of its elements, so as_operand() takes the element type from the type of the argument, keeping volatile; the type without qualifiers, std::remove_cvref_t, would turn a volatile int[3] into an int[3], and the conversion to a pointer to const int would not compile. The tests are in operands.cpp and in the static assertions.

The concept character selects the five types (signed char and unsigned char are bytes, and their arrays are array_operands). A basic_c_string is compared with the strings and string views of the same character type, as std::basic_string_view is, so L"abc" is comparable with a std::wstring but not with a std::string. A basic_c_string refers to the characters, and does not copy them; a std::string operand, in contrast, is an ordinary operand, and is copied or moved like any other.

The types deduced for some common calls are:

CallType
eq (a, b), with int a, bcompare_op<int, int, equal_to>
eq (a, 2)compare_op<int, int, equal_to>
eq (s, "abc"), with std::string scompare_op<std::string, c_string, equal_to>
eq (L"abc", ws), with std::wstring wscompare_op<basic_c_string<wchar_t>, std::wstring, equal_to>
negate (e), with e the first expressionnot_op<compare_op<int, int, equal_to>>

The constraints at the call site​

The requires clause of eq() rejects the invalid calls at the call site, with a short diagnostic, instead of an error deep inside apply():

  • printable requires that each operand can be written by the expression formatter, since a failed check writes it;
  • comparable requires that the operands can be compared by the selected operator, after as_operand(), or that they are integers of different signedness.

The two conditions are joined in the concept comparable_operands, which selects eq(). For the operands that do not satisfy it, eq() has a second overload, selected by the negated concept, as expect() has for the values that are not conditions. It never runs: it calls reject_comparison(), whose static_assert states why the call is rejected (an operand that cannot be written first, then the operator that the operands do not support), and returns false, so that the check that contains the call gives no further errors. The same holds for the other five comparators.

Since the call of the comparators is well formed for any operands, a program cannot test whether two types are comparable by trying to call them; it tests detail::comparable_operands instead.

comparable is a single concept, parametrised by the comparison enumeration, so that the six comparators share it. Its check of the operator is made in a namespace with the same using-declarations as apply(), so that it accepts exactly what apply() compiles:

inlines/detail-inlines.h (simplified)
namespace comparison_lookup
{
using std::operator==;
// ... and the other five operators

template <comparison Op, class Lhs_T, class Rhs_T>
concept can_compare
= (Op == comparison::equal
and requires (const Lhs_T& lhs, const Rhs_T& rhs) { lhs == rhs; })
or (Op == comparison::not_equal
and requires (const Lhs_T& lhs, const Rhs_T& rhs) { lhs != rhs; })
// ... and the other four
;
}

How the operands are stored​

The type with which an expression stores an operand is chosen by stored_operand_t, applied to the type deduced for a forwarding reference:

detail.h
template <class T>
using stored_operand_t = std::conditional_t<
std::is_lvalue_reference_v<T>
and not std::is_copy_constructible_v<std::remove_cvref_t<T>>,
const std::remove_reference_t<T>&, std::remove_cvref_t<T>>;

The rules, and their effect, measured with a type that counts its copies and moves, are:

OperandStored aseq (x, y) with two such operands
an lvalue (a named object) of a copyable typea copy2 copies
an rvalue (a temporary)the value, moved2 moves
an lvalue of a type that cannot be copieda const referenceno copy, no move

The reasons are:

  • Copying the named operands keeps an expression valid after the objects it was built from are gone. An expression built in a function from its local variables, and returned, does not refer to the destroyed locals:

    auto make_check (int x)
    {
    int local = x;
    return mt::eq (local, 42); // stores a copy of local
    }

    t.expect (make_check (42));

    The C strings and the other arrays are an exception: they are stored as a basic_c_string or an array_operand, which refer to the original characters or elements (see The operands that change type), so an expression built from a local array must not outlive the array.

  • Moving the temporaries keeps an expression valid after the end of the statement, as in auto op = eq (f (), 1);, and avoids the cost of a copy.

  • Referring to the objects that cannot be copied is the only option for them; the reference remains valid while the named object exists, which is always the case within the statement that checks the expression.

The cost of the copies is paid in every check, passed or failed, and is significant for large objects, such as a long std::vector. It is a deliberate trade-off, made for the safety of the returned expressions: an expression that referred to the named operands would dangle, without a diagnostic, when it is returned from the function that builds it. A view copies only a pointer and a size, so comparing a std::string_view of a string makes no allocation, where comparing the strings makes two.

The operand is evaluated once, when the argument of the comparator is evaluated; eq (f (), 42) calls f() once, and the stored value is used both for the result and for the report.

Approximate comparisons​

approx (lhs, rhs, epsilon) creates an approx_op, which stores the three values and the result; the operands are not passed through as_operand(), since only arithmetic types are accepted (the concept approx_operand excludes bool and the character types):

inlines/detail-inlines.h (simplified)
template <class Lhs_T, class Rhs_T, class Epsilon_T>
template <class L, class R, class E>
constexpr approx_op<Lhs_T, Rhs_T, Epsilon_T>::approx_op (L&& lhs, R&& rhs,
E&& epsilon)
: binary_op<Lhs_T, Rhs_T>{ std::forward<L> (lhs), std::forward<R> (rhs),
detail::differ_by_at_most (
detail::unwrap (lhs), detail::unwrap (rhs),
detail::unwrap (epsilon)) },
epsilon_{ std::forward<E> (epsilon) }
{
}

differ_by_at_most() computes the distance exactly when all three values are integers, as an unsigned value, so that it neither overflows nor wraps around; otherwise it converts the values to their common floating-point type. A negative tolerance, or a NaN, makes the result false. The tolerance is reported with the operands, as 1.0 ~ (1.2 +/- 0.1).

The logical functions​

all_of() and any_of() take two or more expressions, or conditions, and nest them, two at a time, into and_op or or_op objects:

inlines/function-comparators-inlines.h (simplified)
template <class First_T, class Second_T, class... Rest_T>
constexpr auto
all_of (First_T&& first, Second_T&& second, Rest_T&&... rest)
{
if constexpr (sizeof...(Rest_T) == 0)
{
return detail::and_op{ std::forward<First_T> (first),
std::forward<Second_T> (second) };
}
else
{
return micro_test_plus::all_of (
detail::and_op{ std::forward<First_T> (first),
std::forward<Second_T> (second) },
std::forward<Rest_T> (rest)...);
}
}

So all_of (eq (a, b), lt (a, 2), gt (b, 0)) has the type:

and_op<and_op<compare_op<int, int, equal_to>,
compare_op<int, int, less>>,
compare_op<int, int, greater>>

The formatter writes the nested operands of the same kind without the inner parentheses, so the expression is reported as (1 == 1 and 1 < 2 and 1 > 0).

The and_op constructor computes its result from the results of its operands, which are already computed:

inlines/detail-inlines.h (simplified)
template <class Lhs_T, class Rhs_T>
template <class L, class R>
constexpr and_op<Lhs_T, Rhs_T>::and_op (L&& lhs, R&& rhs)
: binary_op<Lhs_T, Rhs_T>{ std::forward<L> (lhs), std::forward<R> (rhs),
static_cast<bool> (lhs)
and static_cast<bool> (rhs) }
{
}

Two consequences follow:

  • There is no short-circuit evaluation. All the arguments of all_of() are evaluated before it is called, so in all_of (ne (p, nullptr), eq (*p, 1)) the pointer is dereferenced even when it is null. A check that depends on an earlier one must be a separate statement, usually a require() followed by an expect().
  • The sub-expressions are moved into the enclosing ones, since they are temporaries; at each level of nesting, the operands are moved again. With three comparisons of two named objects, a type that counts its copies and moves shows 6 copies (2 for each comparison) and 10 moves (4 at the first level of nesting, 6 at the second).

negate() creates a not_op, which stores its operand as stored_operand_t selects, and whose result is the negation of the result of its operand.

Predicates​

predicate_op is the unary counterpart of compare_op: a tag with a symbol, written before the operand, and an apply() that takes one operand. The framework defines no predicate of its own; the template exists for the applications, which use it through the extension namespace.

Exceptions​

When exceptions are enabled, throws<E> (f), throws (f), and nothrow (f) create objects derived from callable_op. These do not store the callable: their constructors call it, immediately, from a lambda that catches what it throws, and store only the outcome:

inlines/detail-inlines.h (simplified)
template <class Exception_T>
template <class Callable_T>
requires std::invocable<Callable_T&>
throws_op<Exception_T>::throws_op (Callable_T&& func)
: callable_op{ [&func] () -> callable_result
{
try
{
func ();
}
catch (const failed_requirement&)
{
throw;
}
catch (const Exception_T&)
{
return { true, throw_outcome::none, {} };
}
catch (const std::exception& e)
{
return { false, throw_outcome::std_exception,
e.what () != nullptr ? e.what () : "" };
}
catch (...)
{
return { false, throw_outcome::unknown_exception, {} };
}
return { false, throw_outcome::none, {} };
}() }
{
}
  • The callable is taken by forwarding reference and called as an lvalue, so a mutable lambda or a functor with a non-const call operator is accepted. The functions throws() and nothrow() and these constructors are constrained by std::invocable<Callable_T&>, so that a value that cannot be called without arguments is rejected at the call, with a diagnostic that names the function and the constraint, not deep inside the constructor.
  • callable_result holds the result, the kind of exception thrown (throw_outcome), and the text of what(), so that the report can say what was thrown instead.
  • failed_requirement is rethrown, so that a failed require() within the callable still stops the test case, instead of counting as an exception thrown by the callable.

Without exceptions, these functions are not defined.

The extension namespace​

The applications create their own comparators with the same templates, through two using-declarations at the end of detail.h:

detail.h
namespace extension
{
using detail::compare_op;
using detail::predicate_op;
}

An application tag is accepted if it satisfies extension::compare_tag (or extension::predicate_tag): a printable symbol, and an apply() whose result converts to bool. The constructors of compare_op and predicate_op require the concept; for a tag that does not satisfy it, a second constructor, selected by the negated concept, only stops the compilation, with a static_assert in reject_compare_tag() or reject_predicate_tag() that says what the tag needs (the symbol first, then the apply()), and builds the base with a false result, so that the error is the only one. The formatter writes the symbol of a tag only when it has one (tag_has_symbol), for the same reason. The expressions so created are ordinary framework expressions: they can be combined with all_of() and negate(), and are reported by the same formatter. The User's Guide shows an example, in Custom comparators.

Adding a comparator​

A new built-in comparator usually needs:

  1. a tag, in detail.h, with its symbol and the declaration of apply(), and the definition of apply() in inlines/detail-inlines.h;
  2. if it is a new comparison operator, a value in the comparison enumeration, and the corresponding alternative in can_compare;
  3. the function, declared in function-comparators.h, with its Doxygen comment and its constraints, and defined in its inlines, following eq();
  4. checks in the unit test program, for the passed and the failed cases, and the updated reference outputs in tests/expected.

A comparator built on compare_op needs no change in the formatter, which already writes any compare_op, with the symbol of its tag (see The expression formatter).