How to improve compiler error messages when using C++ std::visit?
Asked Answered
H

4

10

I am using C++17's std::visit() function on a variant with many alternatives, and the error messages produced by the compiler whenever I forget one or more of the alternatives in my visitor are quite difficult to understand.

e.g.

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

using Foo = std::variant<A, B, /* ... many more alternatives ... */>;

Foo foo;

std::visit(overloaded{
    [](A const& a) { /* ... */ },
    [](B const& b) { /* ... */ },
    /* ... forgot 1+ alternatives ... */
    }, foo
);

In the above code example, the compiler can produce error messages that are thousands of characters in length, depending on the number of alternatives. Is there a way to improve these error messages so that the compiler will output something like the following instead?

example.cc:8-13: error: Non-exhaustive visitor -- missing alternative of type 'X'
Heyes answered 5/6, 2022 at 13:9 Comment(5)
Short of writing your own wrapper for visit - no. But even though the errors are horrible, there aren't too many possible causes: either a missing alternative or a mismatching return type.Destruct
Unfortunately, no. Welcome to C++, where compilation errors are often longer than the code getting compiled.Kip
Inscrutable error messages has been plaguing C++ for a long time. Bjarne mentioned many times about that concern, and is an area in C++ he hopes can be improved.Stovall
You could use compiler explorer and have g++, clang++ and MSVC running at the same time. One of them will hopefully give an error message that is understandable :-)Contradiction
@Destruct How would I go about writing this wrapper? Would it be fairly simple to implement, or would I essentially be rewriting std::visit()?Heyes
H
2

My first attempt at solving this problem can be found here. After some some googling and lots of trial and error, I've come up with a much better solution, which I've posted here. I'll copy-paste the solution, below, for convenience.


Here is a proof of concept.

#include <iostream>
#include <variant>


template <typename> class Test { };

using Foo = std::variant<
    Test<struct A>,
    Test<struct B>,
    Test<struct C>,
    Test<struct D>
    >;

using Bar = std::variant<
    Test<struct E>,
    Test<struct F>,
    Test<struct G>,
    Test<struct H>,
    Test<struct I>,
    Test<struct J>,
    Test<struct K>,
    Test<struct L>
    >;


template <typename T>
struct DefineVirtualFunctor
{
    virtual int operator()(T const&) const = 0;
};

template <template <typename> typename Modifier, typename... Rest>
struct ForEach { };
template <template <typename> typename Modifier, typename T, typename... Rest>
struct ForEach<Modifier, T, Rest...> : Modifier<T>, ForEach<Modifier, Rest...> { };

template <typename Variant>
struct Visitor;
template <typename... Alts>
struct Visitor<std::variant<Alts...>> : ForEach<DefineVirtualFunctor, Alts...> { };


struct FooVisitor final : Visitor<Foo>
{
    int operator()(Test<A> const&) const override { return  0; }
    int operator()(Test<B> const&) const override { return  1; }
    int operator()(Test<C> const&) const override { return  2; }
    int operator()(Test<D> const&) const override { return  3; }
};

struct BarVisitor final : Visitor<Bar>
{
    int operator()(Test<E> const&) const override { return  4; }
    int operator()(Test<F> const&) const override { return  5; }
    int operator()(Test<G> const&) const override { return  6; }
    int operator()(Test<H> const&) const override { return  7; }
    int operator()(Test<I> const&) const override { return  8; }
    int operator()(Test<J> const&) const override { return  9; }
    int operator()(Test<K> const&) const override { return 10; }
    int operator()(Test<L> const&) const override { return 11; }
};


int main(int argc, char const* argv[])
{
    Foo foo;
    Bar bar;
    
    switch (argc) {
    case  0: foo = Foo{ std::in_place_index<0> }; break;
    case  1: foo = Foo{ std::in_place_index<1> }; break;
    case  2: foo = Foo{ std::in_place_index<2> }; break;
    default: foo = Foo{ std::in_place_index<3> }; break;
    }
    switch (argc) {
    case  0: bar = Bar{ std::in_place_index<0> }; break;
    case  1: bar = Bar{ std::in_place_index<1> }; break;
    case  2: bar = Bar{ std::in_place_index<2> }; break;
    case  3: bar = Bar{ std::in_place_index<3> }; break;
    case  4: bar = Bar{ std::in_place_index<4> }; break;
    case  5: bar = Bar{ std::in_place_index<5> }; break;
    case  6: bar = Bar{ std::in_place_index<6> }; break;
    default: bar = Bar{ std::in_place_index<7> }; break;
    }
    
    std::cout << std::visit(FooVisitor{ }, foo) << "\n";
    std::cout << std::visit(BarVisitor{ }, bar) << "\n";

    return 0;
}

As you can see, the Visitor class template accepts a std::variant type as a template parameter, from which it will define an interface that must be implemented in any child classes that inherit from the template class instantiation. If, in a child class, you happen to forget to override one of the pure virtual methods, you will get an error like the following.

$ g++ -std=c++17 -o example example.cc
example.cc: In function ‘int main(int, const char**)’:
example.cc:87:41: error: invalid cast to abstract class type ‘BarVisitor’
   87 |     std::cout << std::visit(BarVisitor{ }, bar) << "\n";
      |                                         ^
example.cc:51:8: note:   because the following virtual functions are pure within ‘BarVisitor’:
   51 | struct BarVisitor final : Visitor<Bar>
      |        ^~~~~~~~~~
example.cc:29:17: note:     ‘int DefineVirtualFunctor<T>::operator()(const T&) const [with T = Test<J>]’
   29 |     virtual int operator()(T const&) const = 0;
      |                 ^~~~~~~~

This is much easier to understand than the error messages that the compiler usually generates when using std::visit().

Heyes answered 5/6, 2022 at 21:11 Comment(0)
H
1

IMO you can wrap the overload set in a function object that executes a default routine on missed cases (much like a default section in a switch statement). And I put the default overload at the beginning, so as to not forget it:

auto any_visitor=[](auto&& val, auto&& default_fn,auto ...fn){
    overloaded vis{fn ...};
    if constexpr(std::is_invokable_v<decltype(vis), decltype(val)>)
        return vis(std::forward(val));
    else
        return std::invoke(std::forward(default_fn), std::forward(val));
};

std::visit(
    std::bind_back(
        any_visitor,
        [](auto&&){
            /* default visitor logic for missed cases*/
        },
        [](A const& a) { /* ... */ },
        [](B const& b) { /* ... */ },
        /* ... forgot 1+ alternatives ... */
    }),
    foo
);

The std::bind_back is not a miracle. It just provides cleaner syntax with no need for place holder parameters:

namespace par=std::placeholders;

std::visit(
    std::bind(
        any_visitor, par::_1//keep the front parameter
        [](auto&&){
            /* default visitor logic for missed cases*/
        },
        [](A const& a) { /* ... */ },
        [](B const& b) { /* ... */ },
        /* ... forgot 1+ alternatives ... */
    }),
    foo
);
Heins answered 5/6, 2022 at 16:14 Comment(2)
I'm unfortunately not able to test this out, since I don't have access to a compiler that supports C++23 and my C++ knowledge is not strong enough to convert it to C++17.Heyes
@Heyes this is just an illustration of the idea. I edited the answer, but someone may still find problems in it. Basic idea is to conditionally call the provided overloads or the fallback default overload that accepts anything. Because you cannot simply make the generic overload work with inheritance. You capture normal overloads by inheritance composition, and fallback to generic default by compiletime condition (if constexpr).Heins
S
1

If you can use C++20, you can make use of concepts. Afaik, improving error messages in templated code was one of the key reasons why people wanted to have this feature. "Simply" wrap std::visit with your own visit function and add a requires clause:

#include <concepts>
#include <variant>

template <class Visitor, class... Ts>
requires (std::invocable<Visitor, Ts> && ...)
auto my_visit(Visitor&& vis, std::variant<Ts...> const& var) 
{
    return std::visit(std::forward<Visitor>(vis), var);
}

Using this wrapper reduces clang's error message from 184 lines down to 28 lines. Live Demo (Note that this implementation only covers const std::variant<...>& and only one of them at a time, while std::visit can handle an arbitrary number of variants at once.)

Unfortunately, it is still not as short and descriptive as the wish you expressed in your question, but it is mush better than the original, I guess.

Side note: I cannot tell you, why C++20's std::visit doesn't have this requires clause built in.

Sacrilegious answered 6/6, 2022 at 13:25 Comment(1)
Unfortunately, I'd prefer to stay away from C++20 until its more widely supported, but I upvoted your answer because it is a very elegant solution. I can't wait to start using concepts, though, in the near future! :)Heyes
H
0

I've come up with a less-than-ideal solution, but it's better than nothing. If a better solution eventually comes along, I will happily switch the accepted answer to that.

Here's a proof of concept.

#include <variant>

#define STD_VISIT_IMPROVE_COMPILER_ERRORS_LAMBDA \
[](auto... __args) { \
    static_assert(always_false_v<decltype(__args)...>, "non-exhaustive visitor"); \
},

template <typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <typename... Ts> overloaded(Ts...) -> overloaded<Ts...>;

template <typename> constexpr bool always_false_v = false;

template <typename> class Test { };

using Foo = std::variant<
    std::monostate,
    Test<struct A>,
    Test<struct B>,
    Test<struct C>,
    Test<struct D>,
    Test<struct E>,
    Test<struct F>,
    Test<struct G>,
    Test<struct H>,
    Test<struct I>,
    Test<struct J>,
    Test<struct K>,
    Test<struct L>,
    Test<struct M>,
    Test<struct N>,
    Test<struct O>,
    Test<struct P>,
    Test<struct Q>,
    Test<struct R>,
    Test<struct S>,
    Test<struct T>,
    Test<struct U>,
    Test<struct V>,
    Test<struct W>,
    Test<struct X>,
    Test<struct Y>,
    Test<struct Z>
    >;

int main(int argc, char const* argv[])
{
    Foo foo;

    switch (argc) {
    case  0: foo = Foo{ std::in_place_index< 0> }; break;
    case  1: foo = Foo{ std::in_place_index< 1> }; break;
    case  2: foo = Foo{ std::in_place_index< 2> }; break;
    case  3: foo = Foo{ std::in_place_index< 3> }; break;
    case  4: foo = Foo{ std::in_place_index< 4> }; break;
    case  5: foo = Foo{ std::in_place_index< 5> }; break;
    case  6: foo = Foo{ std::in_place_index< 6> }; break;
    case  7: foo = Foo{ std::in_place_index< 7> }; break;
    case  8: foo = Foo{ std::in_place_index< 8> }; break;
    case  9: foo = Foo{ std::in_place_index< 9> }; break;
    case 10: foo = Foo{ std::in_place_index<10> }; break;
    case 11: foo = Foo{ std::in_place_index<11> }; break;
    case 12: foo = Foo{ std::in_place_index<12> }; break;
    case 13: foo = Foo{ std::in_place_index<13> }; break;
    case 14: foo = Foo{ std::in_place_index<14> }; break;
    case 15: foo = Foo{ std::in_place_index<15> }; break;
    case 16: foo = Foo{ std::in_place_index<16> }; break;
    case 17: foo = Foo{ std::in_place_index<17> }; break;
    case 18: foo = Foo{ std::in_place_index<18> }; break;
    case 19: foo = Foo{ std::in_place_index<19> }; break;
    case 20: foo = Foo{ std::in_place_index<20> }; break;
    case 21: foo = Foo{ std::in_place_index<21> }; break;
    case 22: foo = Foo{ std::in_place_index<22> }; break;
    case 23: foo = Foo{ std::in_place_index<23> }; break;
    case 24: foo = Foo{ std::in_place_index<24> }; break;
    case 25: foo = Foo{ std::in_place_index<25> }; break;
    default: foo = Foo{ std::in_place_index<26> }; break;
    }

    return std::visit(overloaded{
        [](std::monostate) { return  0; },
        [](Test<A> const&) { return  1; },
        [](Test<B> const&) { return  2; },
        [](Test<C> const&) { return  3; },
        [](Test<D> const&) { return  4; },
        [](Test<E> const&) { return  5; },
        [](Test<F> const&) { return  6; },
        [](Test<G> const&) { return  7; },
        [](Test<H> const&) { return  8; },
        [](Test<I> const&) { return  9; },
        [](Test<J> const&) { return 10; },
        [](Test<K> const&) { return 11; },
        [](Test<L> const&) { return 12; },
        [](Test<M> const&) { return 13; },
        [](Test<N> const&) { return 14; },
        [](Test<O> const&) { return 15; },
        [](Test<P> const&) { return 16; },
        [](Test<Q> const&) { return 17; },
        [](Test<R> const&) { return 18; },
        [](Test<S> const&) { return 19; },
        [](Test<T> const&) { return 20; },
        [](Test<U> const&) { return 21; },
        [](Test<V> const&) { return 22; },
        [](Test<W> const&) { return 23; },
//      [](Test<X> const&) { return 24; },  // Whoops...
        [](Test<Y> const&) { return 25; },
        [](Test<Z> const&) { return 26; },
        STD_VISIT_IMPROVE_COMPILER_ERRORS_LAMBDA
        }, foo
    );
}

When compiling with -fmax-errors=1 (GCC) or -ferror-limit=1 (Clang), the STD_VISIT_IMPROVE_COMPILER_ERRORS_LAMBDA causes the static assert message to print out, explaining the error. Unfortunately, however, it does not tell us which alternative is unsatisfied and it still does not prevent the original, long and practically-unintelligible compiler error from being generated. At least, though, the cause of the error is more clear.

e.g.

$ g++ -std=c++17 -fmax-errors=1 -o example example.cc
...
example.cc:5:19: error: static assertion failed: non-exhaustive visitor
    5 |     static_assert(always_false_v<decltype(__args)...>, "non-exhaustive visitor"); \
      |                   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
example.cc:107:9: note: in expansion of macro ‘STD_VISIT_IMPROVE_COMPILER_ERRORS_LAMBDA’
  107 |         STD_VISIT_IMPROVE_COMPILER_ERRORS_LAMBDA
      |         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated due to -fmax-errors=1.
Heyes answered 5/6, 2022 at 14:42 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.