17

Herb Sutter's Back to the Basics! Essentials of Modern C++ presentation at CppCon discussed different options for passing parameters and compared their performance vs. ease of writing/teaching. The 'advanced' option (providing the best performance in all the cases tested, but too difficult for most developers to write) was perfect forwarding, with the example given (PDF, pg. 28):

class employee {
    std::string name_;

public:
    template <class String,
              class = std::enable_if_t<!std::is_same<std::decay_t<String>,
                                                     std::string>::value>>
    void set_name(String &&name) noexcept(
      std::is_nothrow_assignable<std::string &, String>::value) {
        name_ = std::forward<String>(name);
    }
};

The example uses a template function with forwarding reference, with the template parameter String constrained using enable_if. However the constraint appears to be incorrect: It seems to be saying this method may be used only if the String type is not a std::string, which makes no sense. That would mean that this std::string member can be set using anything but an std::string value.

using namespace std::string_literals;

employee e;
e.set_name("Bob"s); // error

One explanation I considered was that there's a simple typo and the constraint was intended to be std::is_same<std::decay_t<String>, std::string>::value instead of !std::is_same<std::decay_t<String>, std::string>::value. However that would imply that the setter doesn't work with, e.g., const char * and it obviously was intended to work with this type given that that's one of the cases tested in the presentation.

It seems to me that the correct constraint is more like:

template <class String,
          class = std::enable_if_t<std::is_assignable<decltype((name_)),
                                                      String>::value>>
void set_name(String &&name) noexcept(
  std::is_nothrow_assignable<decltype((name_)), String>::value) {
    name_ = std::forward<String>(name);
}

allowing anything that can be assigned to the member to be used with the setter.

Have I got the right constraint? Are there other improvements that can be made? Is there any explanation for the original constraint, perhaps it was taken out of context?


Also I wonder if the complicated, 'unteachable' parts of this declaration are really that beneficial. Since we're not using overloading we can simply rely on normal template instantiation:

template <class String>
void set_name(String &&name) noexcept(
  std::is_nothrow_assignable<decltype((name_)), String>::value) {
    name_ = std::forward<String>(name);
}

And of course there's some debate over whether noexcept really matters, some saying not to worry too much about it except for move/swap primitives:

template <class String>
void set_name(String &&name) {
    name_ = std::forward<String>(name);
}

Maybe with concepts it wouldn't be unreasonably difficult to constrain the template, simply for improved error messages.

template <class String>
  requires std::is_assignable<decltype((name_)), String>::value
void set_name(String &&name) {
    name_ = std::forward<String>(name);
}

This would still have the drawbacks that it can't be virtual and that it must be in a header (though hopefully modules will eventually render that moot), but this seems fairly teachable.

bames53
  • 86,085
  • 15
  • 179
  • 244
  • Well, someone in the room said it's constraint incorrectly and wouldn't accept string literals -- maybe we can cc @HowardHinnant ? It doesn't look right to me either. [Video of the talk](http://youtu.be/xnqTKD8uD64?t=1h15m21s) – dyp Oct 01 '14 at 19:26
  • @PiotrS.: MSVC doesn't have aliases, so I forgot :/ – Mooing Duck Oct 01 '14 at 21:10
  • @MooingDuck Hey, VS2013 supports aliases and their stdlib implements the C++14 type_trait aliases. – bames53 Oct 01 '14 at 21:17
  • @bames53: It does? I wonder how I overlooked that! – Mooing Duck Oct 01 '14 at 21:19

3 Answers3

4

I think what you have is probably right, but in the interest of not writing an "answer" that is simply "I agree", I will propose this instead that will check assignment based on the correct types - whether it's an lval, rval, const, whatever:

template <class String>
auto set_name(String&& name) 
-> decltype(name_ = std::forward<String>(name), void()) {
    name_ = std::forward<String>(name);
}
Barry
  • 286,269
  • 29
  • 621
  • 977
  • 1
    It's going to get somewhat uglier, of course, when you add the `noexcept` clause back in. – Ben Voigt Apr 12 '15 at 17:55
  • This literally saved my life. I couldn't get any of the is_* type_traits working, and using decltype like that was escaping me. Thanks so much! – Fabio A. Oct 19 '19 at 02:59
4

One explanation I considered was that there's a simple typo and the constraint was intended to be std::is_same<std::decay_t<String>, std::string>::value instead of !std::is_same<std::decay_t<String>, std::string>::value.

Yes, the right constraint that was presented on the screen was is_same, not !is_same. Looks like there is a typo on your slide.


However that would imply that the setter doesn't work with, e.g., const char *

Yes, and I believe this was done on purpose. When a string literal like "foo" is passed to a function accepting a universal reference, then the deduced type is not a pointer (since arrays decay to pointers only when caught in template parameter by value), rather, it is a const char(&)[N]. That said, every call to set_name with string literal of different length would instantiate a new set_name specialization, like:

void set_name(const char (&name)[4]); // set_name("foo");
void set_name(const char (&name)[5]); // set_name("foof");
void set_name(const char (&name)[7]); // set_name("foofoo");

The constraint is supposed to define the universal reference so that it accepts and deduces only either std::string types for rvalue arguments, or cv-std::string& for lvalue arguments (that's why it is std::decayed before comparing to std::string in that std::is_same condition).


it obviously was intended to work with this type given that that's one of the cases tested in the presentation.

I think the tested version (4) was not constrained (note it was named String&&+perfect forwarding), so it could be as simple as:

template <typename String>
void set_name(String&& name)
{
    _name = std::forward<String>(name);
}

so that when a string literal is passed, it does not construct the std::string instance prior to function call just like the non-templated versions would do (unnecessarily allocating memory in callee's code just to build a std::string temporary that would be eventually moved to possibly preallocated destination like _name):

void set_name(const std::string& name);
void set_name(std::string&& name);

It seems to me that the correct constraint is more like: std::enable_if_t<std::is_assignable<decltype((name_)), String>::value>>

No, as I wrote, I don't think the intent was to constrain the set_name to accept types assignable to std::string. Once again - that original std::enable_if is there to have a single implementation of set_name function taking a universal reference accepting only std::string's rvalues and lvalues (and nothing more although this is a template). In your version of std::enable_if passing anything non-assignable to std::string would yield an error no matter if the constaint is or not when tried to do so. Note that eventually we might be just moving that name argument to _name if it's non-const rvalue reference, so checking assignability is pointless except from situations when we are not using a SFINAE to exclude that function from overload resolution in favor of other overload.


What's the correct enable_if constraint on perfect forwarding setter?

template <class String,
          class = std::enable_if_t<std::is_same<std::decay_t<String>,
                                                std::string>::value>>

or no constraint at all if it won't cause any performance hit (just like passing string literals).

Piotr Skotnicki
  • 46,953
  • 7
  • 118
  • 160
  • *"I don't think the intent was to constrain the set_name to accept types assignable to `std::string`"* If we compare it to the other solutions, it should at least be implicitly convertible to `std::string` to allow the same conversions as the other solutions. But that seems inappropriate, since we do *not* want to perform that implicit conversion before assigning. – dyp Oct 01 '14 at 21:22
  • @dyp: that's a good reason why the type is constrained to `std::string`s only, not to types *assignable* to string, is that what you mean? – Piotr Skotnicki Oct 01 '14 at 21:36
  • Well I mean the other solutions allow `x.set_name("hello");`, whereas this one doesn't - `"hello"` is not a `std::string`, but it is implicitly convertible to `std::string&&` and `std::string const&` and `std::string`. – dyp Oct 01 '14 at 21:42
  • From the [video](http://youtu.be/xnqTKD8uD64?t=1h15m21s) it looks like it was presented as `!is_same`, and from Herb's discussion I believe he clearly wanted to support string literals, though you raise a good point about intiantiating the template for each length of string literal used. -- Also, I mentioned that the constraint could just be left out since we're not using overloading, but I proposed a replacement since Herb argued that perfect forwarders should be constrained. Plus some compilers understand `enable_if` and use it to improve diagnostics. – bames53 Oct 01 '14 at 21:51
  • 2
    @bames53: don't look at the slide in video, look at the large screen behind Herb (I see there `is_same`). and he even asks if it supports string literals (the audience says "it does not") and why not ("why it's *wrongly* constrained") - at least that's what I see and hear. I think that adding `is_assignable` constraint does not even improve diagnostics, the error will eventually be raised if the deduced argument's type is not *copy-assignable* / *move-assignable* to `std::string` – Piotr Skotnicki Oct 01 '14 at 22:00
  • @dyp you could constrain the template with `is_convertible_t<>`, however that could cause a problem in the case where the type is convertible but whatever conversion/overload resolution happens doesn't actually use that conversion path. [for example](http://coliru.stacked-crooked.com/a/48a8beffbe97de65). `is_assignable` will check for exactly the conversions that actually get used, but will delay those conversions until necessary inside the context of `set_name`. – bames53 Oct 01 '14 at 22:06
  • @bames53 That's why I said that it "seems inappropriate" - in order to use *just* the guarantee of `is_convertible`, one had to write the assignment as `_name = std::string( std::forward(name) );` which defeats the purpose of the `String&&` version. – dyp Oct 01 '14 at 22:14
  • @PiotrS. Ah, you're right about the slides being different. My interpretation of the whole "it doesn't accept string literals because it's wrongly constrained" is that: Herb wants it to accept string literals and believes it's correctly constrained to allow them, and that if it is wrongly constrained and doesn't allow them then it's Howard's fault. I never hear any explicit statement from Herb that he intents to prohibit string literals, and his test clearly uses `char*`. I'll see if I can get Herb's attention to clear this up. – bames53 Oct 01 '14 at 22:17
  • @bames53: I doubt Herb has used the constrained version for testing purposes with `const char*`. To me it looks he tested just `String&&` with no constraint as I wrote in my answer, but it would be great to have this clarified for 100%. – Piotr Skotnicki Oct 01 '14 at 22:26
  • @bames53 I thought Herb asked sardonically something like "Is it incorrectly constrained?", because he wanted to make a broader point about avoiding complexity. He was implying that (whether the constraint is correct or not), if we can't be sure that Howard Hinnant got it right, then we maybe shouldn't trust ourselves to do it then either in the vast majority of cases. – Tim Seguine Oct 10 '14 at 09:33
  • @bames53 Yes, I agree that was his point in asking in the first place, but I also think he intended to show a correct constraint. – bames53 Oct 10 '14 at 13:08
1

I tried to fit these thoughts in a comment, but they would not fit. I presume to write this as I am mentioned both in the comments above, and in Herb's great talk.

Sorry to be late to the party. I have reviewed my notes and indeed I am guilty of recommending to Herb the original constraint for option 4 minus the errant !. Nobody is perfect, as my wife will assuredly confirm, least of all me. :-)

Reminder: Herb's point (which I agree with) is start with the simple C++98/03 advice

set_name(const string& name);

and move from there only as needed. Option #4 is quite a bit of movement. If we are considering option #4, we are down to counting loads, stores and allocations in a critical part of the application. We need to assign name to name_ just as fast as possible. If we are here, code readability is far less important than performance, though correctness is still king.

Providing no constraint at all (for option #4) I believe to be slightly incorrect. If some other code attempted to constrain itself with whether or not it could call employee::set_name, it could get the wrong answer if set_name is not constrained at all:

template <class String>
auto foo(employee& e, String&& name) 
-> decltype(e.set_name(std::forward<String>(name)), void()) {
    e.set_name(std::forward<String>(name));
    // ...

If set_name is unconstrained and String deduces to some completely unrelated type X, the above constraint on foo improperly includes this instantiation of foo in the overload set. And correctness is still king...

What if we want to assign a single character to name_? Say A. Should it be allowed? Should it be wicked fast?

e.set_name('A');

Well, why not?! std::string has just such an assignment operator:

basic_string& operator=(value_type c);

But note that there is no corresponding constructor:

basic_string(value_type c);  // This does not exist

Therefore is_convertible<char, string>{} is false, but is_assignable<string, char>{} is true.

It is not a logic error to try to set the name of a string with a char (unless you want to add documentation to employee that says so). So even though the original C++98/03 implementation did not allow the syntax:

e.set_name('A');

It did allow the same logical operation in a less efficient manner:

e.set_name(std::string(1, 'A'));

And we are dealing with option #4 because we are desperate to optimize this thing to the greatest degree possible.

For these reasons I think is_assignable is the best trait to constrain this function. And stylistically I find Barry's technique for spelling this constraint quite acceptable. Therefore this is where my vote goes.

Also note that employee and std::string here are just examples in Herb's talk. They are stand-ins for the types you deal with in your code. This advice is intended to generalize to the code that you have to deal with.

Community
  • 1
  • 1
Howard Hinnant
  • 206,506
  • 52
  • 449
  • 577
  • That's a good point about needing a constraint to prevent assigning an int to `name_`, however it seems to me that it's a special case due to the fact that an implicit conversion from `int` to `char` is applied. Ideally the string class would be able to disallow implicit conversions in this case, but I think at least we can do something in `employee::set_name`. What about [this](http://coliru.stacked-crooked.com/a/b25e0861fbeb7c00)? Or maybe using braces for this is too subtle and is therefore just as unteachable as SFINAE constraints? – bames53 Apr 12 '15 at 18:16
  • @bames53: My mistake in using `int` as an example "other type" in my example. I should have used `class rutabaga` and demoed that `rutabaga` has no conversions to `std::string`. However sometimes `employee`s eat `rutabaga`s, and so it is conceivable that `foo` gets overloaded on `employee` and `rutabaga`, or something that `rutabaga` implicitly converts to such as `vegetable`, or a templated parameter representing a `vegetable`. I.e. with extended SFINAE, *any* expression can be used as a constraint, even `e.set_name`. – Howard Hinnant Apr 12 '15 at 20:45
  • But if you constrain with `is_assignable`, your `foo` with `String = int` will still be in the overload set, because `int` (and `double`, etc.) is convertible to `char`. – T.C. Apr 12 '15 at 22:14