Is overloading on universal references now much safer with concepts in c++ 20
Asked Answered
I

2

7

In the book "Effective Modern C++" by Scott Meyers the advice is given (item 26/27) to "Avoid overloading on universal references". His rationale for this is that in almost all calls to an overloaded function that includes a universal reference, the compiler resolves to the universal reference even though that is often not the function you intend for it to resolve. (so this code is bad I think?)

template <typename T>
void foo(T&& t) {
  // some sort of perfect forwarding
}
void foo(string&& t) {
  // some sort of move operation
}

The example above is highly contrived and could likely be replaced with 2 functions.

Another example that I think would be harder to resolve and is far less contrived is one he actually gives in Item 26.

class Foo {
// a decent amount of private data that would take a while to copy
public:
  // perfect forwarding constructor, usually the compiler resolves to this...
  template <typename T>
  explicit Foo(T&& t) : /* forward the information for construction */ {}
  // constructor with some sort of rValue
  explicit Foo(int);
// both the below are created by the compiler he says
  // move constructor
  Foo(Foo&& foo);
  // copy constructor (used whenever the value passed in is const)
  Foo(const Foo& foo);
}
// somewhere else in the code
Foo f{5};
auto f_clone(f);

Scott explains that instead of calling a move constructor or copy constructor, the forwarding constructor gets called in auto f_clone(f) because the compiler rules are to resolve to the forward constructor first.

In the book, he explains alternatives to this and a few other examples of overloading on a universal reference. Most of them seem like good solutions for C++11/14/17 but I was thinking there were simpler ways to solve these problems with C++20 concepts. the code would be identical to the code above except for some sort of constraint on the forwarding constructor:

template <typename T>
  requires = !(typename Foo) // not sure what would need to be put here, this is just a guess
explicit Foo(T&& t) : /* forward the information for construction */ {}

I don't know if that would be the correct syntax, I'm super new to C++ concepts

To me, C++ concepts applied to the forwarding function seem like a general solution that could be applied in every case but I'm not sure

there are multiple parts to my question:

  • is there even a way to disallow a specific type using C++ concepts? (perhaps similar to what I did)
  • is there a better way to tell the compiler not to use the forwarding constructor? (I don't want to have to make the variable I'm copying constant or explicitly define the copy/move constructors if I don't need to)
  • If there is a way to do what I'm suggesting, would this be a universally applicable solution to the problem Scott Meyers expresses?
  • Does applying a template constraint to a type automatically stop the type from being a universal reference?
Impostor answered 23/4, 2022 at 22:39 Comment(0)
M
5

I would say no. I mean, concepts help, because the syntax is nicer than what we had before, but it's still the same problem.

Here's a real-life example: std::any is constructible from any type that is copy constructible. So there you might start with:

struct any {
    template <class T>
        requires std::copy_constructible<std::decay_t<T>>
    any(T&&);

    any(any const&);
};

The problem is, when you do something like this:

any a = 42; // calls any(T&&), with T=int
any b = a;  // calls any(T&&), with T=any

Because any itself is, of course, copy constructible. This makes the constructor template viable, and a better match since it's a less-const-qualified reference.

So in order to avoid that (because we want b to hold an int, and not hold an any that holds an int) we have to remove ourselves from consideration:

struct any {
    template <class T>
        requires std::copy_constructible<std::decay_t<T>>
              && (!std::same_as<std::decay_t<T>, any>)
    any(T&&);

    any(any const&);
};

This is the same thing we had to do in C++17 and earlier when Scott Meyers wrote his book. At least, it's the same mechanism for resolving the problem - even if the syntax is better.

Masonmasonic answered 23/4, 2022 at 23:22 Comment(11)
It seems this much complexity would only be needed where we previously were required to use std::enable_if. aren't there still quite a few cases where concepts drastically reduce the overhead and mental effort required? or is this much complexity required in most cases?Impostor
@jaredjacobson The cases were concepts "drastically reduce the overhead" are those cases where you can add a constraint today but you could not write enable_if (like on class template specializations, non-template member functions of class templates incuding special members, and inline) and where overloading is involved (where writing a cascading layer of overloads is quite challenging with enable_if since they need to be mutually exclusive).Masonmasonic
Wouldn't it be better to have e special constraint for ctor single inputs: template<typename T> concept bool initial_reference = is_const_v<T>==is_rvalue_reference_v<T>; then template<initial_reference T> any::any(T&&);Stelly
@Stelly I don't know what problem that would solve? That concept is checking that you're being invoked with an lvalue or a non-const rvalue, which doesn't seem super useful?Masonmasonic
@Masonmasonic It is not super usefull. Just good for constructors; more specifically, converting constructors. A constructor accepting none-const rvalue is most probably a resource aquisition; Accepting const reference is convertion; accepting none-const rvalue is ownership. This concept filters resource aquisition out and since there is not much practical value in valueness of const input, filters the rvalue const case. But there's a typo instead of xor I triggered xnor. Fix:concept bool initial_reference = is_const_v<T>!=is_rvalue_reference_v<T>;Stelly
@Masonmasonic to your first comment, would concepts allow me to overload on universal references in more cases than before or should I still defer to the advice that Scott gives to avoid overloading on universal references when possible?Impostor
@Stelly I looked at the code that barry commented on and changed it in the way you suggested but it doesn't seem to solve the issue I was asking about with overloading forwarding operators.Impostor
@Stelly I still don't know what problem you're trying to solve. Note that is_rvalue_reference_v<T> is a long way to say false in this context.Masonmasonic
@jaredjacobson First, forwarding reference is a better term. Second, concepts don't allow more cases to overload on function templates - you always could, they just make it syntactically nicer to do so and the ordering rules make it easier. But in cases where you have a forwarding reference overload and a non-template, you still have to be just as careful (as in the any example I showed).Masonmasonic
And, of course, struct myany : std::any can break this, as can struct foo { operator std::any()const; }. The fun part is asking how it breaks it without consulting the standard or compiling your code -- does it unexpectedly call the any ctor, or unexpectedly not call it? (Failing to call std::any ctor from myany is LSP violation)Gerent
@Masonmasonic that concept filters out none-const lvalues and const rvalues. Instead of decaying arguments, unwanted qualifications are discarded before overload resolution begins and then an excessive layer of type decorations are prevented. Next achieve ment is that the template ctor no more creates unnecessary over-specific specializations anymore. So the none-template overloads are better matches. any is not supposed to store the cv_ref qualifications of its operand; so why define 8 variations of ctor per-type, while only 2 can (4 if volatile is acceptable) do? A const lvalue and an rvalueStelly
C
2

is there even a way to disallow a specific type using C++ concepts? (perhaps similar to what I did)

Yes, you can achieve this by adding a constraint that T is not same as the type you don't want.

But remember to remove const, volatile and reference from T if you also don't want them. Because when the function is potentially invoked with a lvalue of type TArg&, T is deduced as TArg& in order to get the argument type as T&& = TArg& && = TArg&. This is reference collapsing and is basically how so-called universal/forwarding-reference works.

template<typename T>
    requires (!std::same_as<Foo, std::remove_cvref_t<T>>)
Foo(T&& t);

//...

Foo f{5};
auto f_clone(f); // calls Foo(const Foo&) with added constraint
                 // (calls Foo(T&&) with T=Foo& without added constraint)

Does applying a template constraint to a type automatically stop the type from being a universal reference?

No, or depends. Constraints are checked after template argument deduction, so forwarding reference still works unless you rule it out with specific constraint like above example code.

Collotype answered 24/4, 2022 at 19:17 Comment(1)
thanks, this is super good information.Impostor

© 2022 - 2024 — McMap. All rights reserved.