Search code examples
c++c++20overload-resolutionc++-conceptsstd-ranges

Why is const char[] a better match for std::ranges::range than for an explicit, const char* free overload, and how to fix it?


I wanted to write a generic << for any range and I ended up with this:

std::ostream& operator << (std::ostream& out, std::ranges::range auto&& range) {
    using namespace std::ranges;

    if (empty(range)) {
        return out << "[]";
    }

    auto current = begin(range);
    out << '[' << *current;

    while(++current != end(range)) {
        out << ',' << *current;
    }

    return out << ']';
}

Tested like so:

int main() {
    std::vector<int> ints = {1, 2, 3, 4};
    std::cout << ints << '\n';
}

it works perfectly and outputs:

[1,2,3,4]

But, when tested with:

int main() {
    std::vector<int> empty = {};
    std::cout << empty << '\n';
}

it outputs, unexpectedly:

[[,], ]

Running this code with a debugger, I came to a conclusion that the problem with empty range is that we run the return out << "[]";. Some C++ magic decided that my, just written,

std::ostream& operator << (std::ostream& out, std::ranges::range auto&& range);

is a better match then the, provided in <ostream>,

template< class Traits >
basic_ostream<char,Traits>& operator<<( basic_ostream<char,Traits>& os,  
                                        const char* s );

so instead of just sending "[]" to the output stream like we are used to see, it recurses back to itself, but with "[]" as the range argument.

What is the reason for that being a better match? Can I fix this in a more elegant manner compared to sending [ and ] separately?


EDIT: It appears that this is most likely a bug in GCC 10.1.0, since the newer versions reject the code.


Solution

  • I think this shouldn't compile. Let's simplify the example a bit to:

    template <typename T> struct basic_thing { };
    using concrete_thing = basic_thing<char>;
    
    template <typename T> concept C = true;
    
    void f(concrete_thing, C auto&&); // #1
    template <typename T> void f(basic_thing<T>, char const*); // #2
    
    int main() {
        f(concrete_thing{}, "");
    }
    

    The basic_thing/concrete_thing mimics what's going on with basic_ostream/ostream. #1 is the overload you're providing, #2 is the one in the standard library.

    Clearly both of these overloads are viable for the call we're making. Which one is better?

    Well, they're both exact matches in both arguments (yes, char const* is an exact match for "" even though we're undergoing pointer decay, see Why does pointer decay take priority over a deduced template?). So the conversion sequences can't differentiate.

    Both of these are function templates, so can't differentiate there.

    Neither function template is more specialized than the other - deduction fails in both directions (char const* can't match C auto&& and concrete_thing can't match basic_thing<T>).

    The "more constrained" part only applies if the template parameter setup is the same in both cases, which is not true here, so that part is irrelevant.

    And... that's it basically, we're out of tiebreakers. The fact that gcc 10.1 accepted this program was a bug, gcc 10.2 no longer does. Although clang does right now, and I believe that's a clang bug. MSVC rejects as ambiguous: Demo.


    Either way, there's an easy fix here which is to write [ and then ] as separate characters.

    And either way, you probably don't want to write

    std::ostream& operator << (std::ostream& out, std::ranges::range auto&& range);
    

    to begin with, since for that to actually work correctly you'd have to stick it in namespace std. Instead, you want to write a wrapper for an arbitrary range and use that instead:

    template <input_range V> requires view<V>
    struct print_view : view_interface<print_view<V>> {
        print_view() = default;
        print_view(V v) : v(v) { }
    
        auto begin() const { return std::ranges::begin(v); }
        auto end() const { return std::ranges::end(v); }
    
        V v;
    };
    
    template <range R>
    print_view(R&& r) -> print_view<all_t<R>>;
    

    And define your operator<< to print a print_view. That way, this just works and you don't have to deal with these issues. Demo.

    Of course, instead of out << *current; you may want to conditionally wrap that in out << print_view{*current}; to be totally correct, but I'll leave that as an exercise.