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:
| Template | Created by | Result | Reported 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 tolerance | 1.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 false | not (1 == 1) |
predicate_op<T, Predicate_T> | the applications, through extension | Predicate_T::apply (operand) | even 7 |
throws_op<Exception_T>, nothrow_op | throws(), nothrow() | the callable threw, or did not | throws, 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
boolthrough aconstexprconversion 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:
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:
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:
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:
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()(andstd::cmp_less()and the others in the other tags), soeq (-1, 1u)is false; the operator of the language would convert-1to a large unsigned value. The character types take part as the integers of the same size and signedness, whichcomparison_integer_tselects (the signedness ofcharandwchar_tis that of the platform), andas_comparison_integer()converts to; without this, the built-in comparison of a signedcharwith anunsigned, or of achar32_twith anint, would convert the signed operand to the unsigned type, as for the integers. Onlyboolis 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:
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:
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):
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, orchar32_t), including a string literal, becomes abasic_c_stringof that type, bounded by the size of the array;c_stringis the alias forchar; - a pointer to a character type becomes a
basic_c_stringbounded by the null terminator (the conceptcharacter_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 withequal_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:
| Call | Type |
|---|---|
eq (a, b), with int a, b | compare_op<int, int, equal_to> |
eq (a, 2) | compare_op<int, int, equal_to> |
eq (s, "abc"), with std::string s | compare_op<std::string, c_string, equal_to> |
eq (L"abc", ws), with std::wstring ws | compare_op<basic_c_string<wchar_t>, std::wstring, equal_to> |
negate (e), with e the first expression | not_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():
printablerequires that each operand can be written by the expression formatter, since a failed check writes it;comparablerequires that the operands can be compared by the selected operator, afteras_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:
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:
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:
| Operand | Stored as | eq (x, y) with two such operands |
|---|---|---|
| an lvalue (a named object) of a copyable type | a copy | 2 copies |
| an rvalue (a temporary) | the value, moved | 2 moves |
| an lvalue of a type that cannot be copied | a const reference | no 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_stringor anarray_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):
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:
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:
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 inall_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 arequire()followed by anexpect(). - 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:
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
mutablelambda or a functor with a non-const call operator is accepted. The functionsthrows()andnothrow()and these constructors are constrained bystd::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_resultholds the result, the kind of exception thrown (throw_outcome), and the text ofwhat(), so that the report can say what was thrown instead.failed_requirementis rethrown, so that a failedrequire()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:
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:
- a tag, in
detail.h, with itssymboland the declaration ofapply(), and the definition ofapply()ininlines/detail-inlines.h; - if it is a new comparison operator, a value in the
comparisonenumeration, and the corresponding alternative incan_compare; - the function, declared in
function-comparators.h, with its Doxygen comment and its constraints, and defined in its inlines, followingeq(); - checks in the
unittest program, for the passed and the failed cases, and the updated reference outputs intests/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).