Reflective Assertions


  • Mod

    Ich hatte in einem neuen Projekt die Notwendigkeit, viele Assertions ueber Beziehungen zwischen verschiedenen Zahlenwerten einzufuegen, und hatte keine Lust, jedes mal die Operanden explizit zu drucken. Habe ein wenig rumgespielt und folgendes herausbekommen, vielleicht findet jemand Interesse daran:

    #pragma once
    
    #include <sstream>
    #include <tuple>
    #include <utility>
    #include <optional>
    
    namespace detail {
        enum class Operator {Lt, Gt, Lte, Gte, Eq, Neq};
        
        constexpr const char* toStr(Operator o) {
            switch (o) {
                case Operator::Lt: return "<";
                case Operator::Lte: return "<=";
                case Operator::Gt: return ">";
                case Operator::Gte: return ">=";
                case Operator::Eq: return "==";
                case Operator::Neq: return "!=";
                default: std::unreachable();
            }
        }
        
        template <typename L>
        struct Handle {
            L&& l;
            constexpr Handle(L&& l) : l(std::forward<L>(l)) {}
            
    #define DEF_OP(Op, Name) \
            template <typename R>\
            constexpr auto operator Op(R&& r) && noexcept { \
                return l Op r? std::nullopt : \
                std::optional{std::tuple<L&&, R&&, Operator>{std::forward<L>(l), std::forward<R>(r), Operator::Name}}; }
            DEF_OP(<, Lt)
            DEF_OP(<=, Lte)
            DEF_OP(>, Gt)
            DEF_OP(>=, Gte)
            DEF_OP(==, Eq)
            DEF_OP(!=, Neq)
    #undef DEF_OP
    
            template <typename F>
            void and_then(F f) {
                if (!l)
                    f(l);
            }
        };
        
        constexpr struct Hook {} hook;
        // <=> is an upper bound in terms of precedence on the operators <,>,<=,>=,==,!=.
        // I.e. for any expression of the form ... < b, it will bind to ... in its entirety.
        template <typename L>
        constexpr auto operator<=>(Hook, L&& l) noexcept {
            return Handle<L>(std::forward<L>(l));
        }
        
        template <typename T> requires(requires (T t) { std::get<0>(t); })
        [[gnu::cold]] constexpr void assert_check(T&& t, const char* stringized) {
            auto&& [l, r, code] = t;
            std::ostringstream ss;
            ss << stringized << " evaluated to false: " << l << ' ' << toStr(code) << ' ' << r;
            throw std::runtime_error(ss.str());
        }
        
        template <typename L>
        [[gnu::cold]] constexpr void assert_check(L&&, const char* stringized) {
                std::ostringstream ss;
                ss << stringized << " evaluated to false";
                throw std::runtime_error(ss.str());
        }
    }
    
    // Lexical possibilities: Expr's least precedent operator...
    // - ...binds stronger than <=>, then Expr is just converted to bool and that's it.
    // - ...is relational/equality. Then we "hook" the left-most operand of such an expression. 
    //   if it's nested unparenthesized, this will be ill-formed (see below)
    // - ...is <=> itself. This is ill-formed, see below.
    // - ...binds weaker than <=>. This will be ill-formed because we produce either a Handle or a tuple<...>, 
    //   neither of which behaves well unless something explicitly accepts it.
    #define Assert_(Expr) (detail::hook <=> Expr).and_then([] (auto&& t) {detail::assert_check(std::forward<decltype(t)>(t), #Expr); return std::optional{false};}) 
    
    
    #ifndef NDEBUG
    #define ASSERT(Expr) Assert_(Expr)
    #else
    #define ASSERT(Expr) ((void)0)
    #endif
    
    #define VERIFY(Expr) Assert_(Expr)
    
    #include <iostream>
    int main() {
        int i, j;
        std::cin >> i >> j;
        ASSERT(j > i); // success
        //ASSERT(j == i); // failure
        ASSERT(j == i + 1); // success
        // ASSERT(i <=> j == std::strong_ordering::equal); // expectedly ill-formed, because <=> cannot be applied to Handle
        ASSERT(j); // success
        ASSERT(i + j); // success
        // ASSERT(i < j < 1); // expectedly ill-formed, because < cannot be applied to a tuple
    }
    

    Beispiel, wenn die erste auskommentierte Zeile ausgefuehrt wird (Coliru):

    terminate called after throwing an instance of 'std::runtime_error'
      what():  j == i evaluated to false: 1 == 0
    

    Die Extraktion der Operanden geschieht hier automatisch durch TMP. Mehr als in main wurde bisher nicht getestet, also keine Korrektheitsgarantie meinerseits.
    Edit: Obiger Code erfordert C++23, ist aber relativ leicht nach C++20 konvertierbar. Um den Code kompatibel mit frueheren Versionen zu machen, muss zumindest der Hook Operator von <=> auf <</>> geaendert werden (was dann AFAICS zu Problemen mit linksseitigen Shift-Ausdruecken fuehrt, da Shift links-rechts Assoziation hat).

    Eine offensichtliche Erweiterung waere Support fuer das reflektieren von komplexen arithmetischen Ausdruecken. Dazu koennte man einen der linksseitigen Teilausdruecke zu einem Boost.Proto terminal konvertieren, und den Syntaxbaum iterieren und drucken. Dafuer gibt es aber seitens Proto keine Loesung, sondern man muesste den Boilerplate komplett selbst verfassen, was mir eindeutig zu viel Arbeit ist.
    Edit^2: Mir faellt gerade auf, dass wir den Baum gar nicht rekursiv iterieren koennen, weil wir ja nur ein terminal auf dem obersten level haben. Naja, genug Operatoren gibt es trotzdem, dass das eine tour de force werden duerfte..
    Edit^3: Jetzt weiss ich wieder, warum ich rekursiv geschrieben hatte. Der C-style Cast bindet relativ stark, deshalb koennen wir bei unparenthetischen Ausdruecken durchaus an einen tieferen Syntaxknoten binden. Das Problem ist nur leider, dass das nur funktioniert, wenn der User zufaellig den Ausdruck richtig zusammensetzt (Teilausdruecke nach links). Lohnt eigentlich schon fast gar nicht, dass noch zum Feature zu machen.




  • Mod

    @hustbaer sagte in Reflective Assertions:

    https://github.com/martinmoene/lest 😉

    Eigentlich wolltest Du wohl eher Catch2 verlinken (fuer reine Assertions, wo Lest AFAICS eher auf Unit Testing fokussiert ist?), welches decomposition ebenfalls implementiert, auch auf eine sehr aehnliche Art und Weise. (Siehe hier und hier). Bin nicht ueberrascht, und es ist nuetzlich zu sehen, wo das bereits implementiert worden ist 👍🏻



  • Ursprünglich wollte ich https://github.com/doctest/doctest verlinken, weil ich es von dort her kenne. Hab dann aber gesehen dass dort steht dass die Assertions von lest genommen wurden: https://github.com/doctest/doctest/blob/master/doctest/doctest.h#L34
    Also hab ich das statt dessen verlinkt. Ob das auch wieder von irgendwas inspiriert war hab ich dann nicht mehr gecheckt.


  • Mod

    @hustbaer Ich fuerchte Du musst deinen Vorschlag wohl wieder zurueck ziehen. https://github.com/catchorg/Catch2/issues/330

    Sorry, but I do not intent for Catch2 to provide assertion macros that work outside the confines of tests.

    However, if someone wants to tear out the assertion and decomposition code and make it into a separate library, they are welcome to though. (As long as they keep to the terms of Boost Software Licence obviously)

    Es gibt keine Moeglichkeit mit Catch2 (und ich nehme an, einer der anderen beiden Libs) freistehende Assertions zu ersetzen. Abgesehen davon wirkt die Infrastruktur ziemlich schwerfaellig und erzeugt overhead, den der obige Code nicht hat (im schnellsten Fall wird nur der unmittelbare Test ausgefuehrt).

    Edit: doctest scheint das zu unterstuetzen: https://github.com/doctest/doctest/blob/master/examples/all_features/asserts_used_outside_of_tests.cpp
    Muss ich mal auf Performance testen.


  • Mod

    Update: Doctest ist überraschend lahm, im Gegenspruch zu DOCTEST_CONFIG_SUPER_FAST_ASSERTS...

    #include <iostream>
    #include <random>
    #include "assert.hpp"
    
    #define DOCTEST_CONFIG_SUPER_FAST_ASSERTS
    #define DOCTEST_CONFIG_IMPLEMENT
    #include <doctest/doctest.h>
    
    int main(int argc, char** argv)
    {
      using namespace std;
    
        // construct a context
        doctest::Context context(argc, argv);
    
        // sets the context as the default one - so asserts used outside of a testing context do not crash
        context.setAsDefaultForAssertsOutOfTestCases();
    
        random_device rd;
        const auto seed = rd();
        mt19937 gen(seed);
        uniform_int_distribution<> distrib_lhs(1, 6);
        uniform_int_distribution<> distrib_rhs(7, 12);
    
        vector<clock_t> times;
        for (int i = 0; i < 500; ++i){
          auto now = clock();
          for (int j = 10000; j-- > 0;) 
            CHECK(distrib_lhs(gen) < distrib_rhs(gen));
          times.push_back(clock() - now);
        }
        sort(times.begin(), times.end());
        cout << "0th, 10th, 50th percentiles:\n";
        cout << "Doctest: " << times[0] << " " << times[50] << " " << times[250] << endl;
    
        times.clear();
        for (int i = 0; i < 500; ++i){
          auto now = clock();
          for (int j = 10000; j-- > 0;) 
            Assert(distrib_lhs(gen) < distrib_rhs(gen));
          times.push_back(clock() - now);
        }
        sort(times.begin(), times.end());
        cout << "Columbo: " << times[0] << " " << times[50] << " " << times[250] << endl;
    }
    
    
    0th, 10th, 50th percentiles:
    Doctest: 206 207 219
    Columbo: 158 159 167
    

    Das ist ein signifikanter Overhead, den ich in hot paths nicht unbedingt in Kauf nehmen wollen wuerde. Zumal Doctest, zwar nicht so furchtbar wie Catch2, immerhin noch die compilation time erhoeht.



  • @Columbo sagte in Reflective Assertions:

    @hustbaer Ich fuerchte Du musst deinen Vorschlag wohl wieder zurueck ziehen. https://github.com/catchorg/Catch2/issues/330

    Ich hab doch gar keinen Vorschlag gemacht. Ich hab bloss ein Projekt (bzw. dann später noch ein anderes) verlinkt, wo auch "reflective assertions" umgesetzt wurden. Ob jetzt im Kontext einer unit-test Library oder frei war mir dabei egal, für mich ist der primär interessante Punkt der Trick mit dem Operator-Overloading.


  • Mod

    @hustbaer Und ich hatte nie behauptet, diesen simplen Trick erfunden zu haben, sondern ein vollstaendiges minimales Tool fuer zero-overhead reflektive Assertions geschrieben zu haben. Anscheinend kann kein anderes Tool genau das. Damit ist dem Anlass meines Posts kein Abbruch getan. 😉



  • Aha, ein Misverständnis 🙂
    Ja, ich wollte damit auch nicht sagen dass dein Beitrag hinfällig oder uninteressant ist. Ich hatte bloss den Eindruck als würdest du keine C++ Library kennen die Reflective Assertions macht. Daher dachte ich mir: ich verlink da mal was. Und das ist auch schon alles.


Anmelden zum Antworten