Какое правильное ограничение enable_if для идеального установщика переадресации?

Назад к основам! Презентация Essentials of Modern C++ на CppCon обсудила различные варианты передачи параметров и сравнила их производительность с простотой написания/обучения. «Расширенный» вариант (обеспечивающий наилучшую производительность во всех протестированных случаях, но слишком сложный для написания большинством разработчиков) был идеальной пересылкой, в приведенном примере (PDF, стр. 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);
    }
};

В примере используется функция шаблона со ссылкой на пересылку, с параметром шаблона String, ограниченным с помощью enable_if. Однако ограничение кажется неверным: кажется, что этот метод можно использовать только в том случае, если тип String не является std::string, что не имеет смысла. Это означает, что этот элемент std::string может быть установлен с использованием всего, кроме значения std::string.

using namespace std::string_literals;

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

Одно объяснение, которое я рассматривал, заключалось в том, что это простая опечатка, и ограничение должно было быть std::is_same<std::decay_t<String>, std::string>::value вместо !std::is_same<std::decay_t<String>, std::string>::value. Однако это будет означать, что сеттер не работает, например, с const char *, и очевидно, что он предназначен для работы с этим типом, учитывая, что это один из случаев, проверенных в презентации.

Мне кажется, что правильное ограничение больше похоже на:

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);
}

позволяя использовать все, что может быть назначено члену, с сеттером.

Есть ли у меня правильное ограничение? Есть ли другие улучшения, которые можно сделать? Есть ли какое-то объяснение исходному ограничению, возможно, оно было вырвано из контекста?


Также мне интересно, действительно ли сложные, «необучаемые» части этой декларации так полезны. Поскольку мы не используем перегрузку, мы можем просто полагаться на обычное создание экземпляра шаблона:

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

И, конечно, есть некоторые споры о том, действительно ли noexcept имеет значение, некоторые говорят, что не стоит слишком беспокоиться об этом, за исключением примитивов перемещения/перестановки:

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

Может быть, с концепциями было бы несложно ограничить шаблон просто для улучшения сообщений об ошибках.

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

У этого все еще есть недостатки, заключающиеся в том, что он не может быть виртуальным и что он должен быть в заголовке (хотя, надеюсь, модули в конечном итоге отобразят это спорным), но это кажется довольно обучаемым.


person bames53    schedule 01.10.2014    source источник
comment
Ну, кто-то в комнате сказал, что это ограничение неверно и не принимает строковые литералы - может быть, мы можем cc @HowardHinnant ? Мне это тоже кажется неправильным. Видео выступления   -  person dyp    schedule 01.10.2014
comment
@PiotrS.: MSVC не имеет псевдонимов, поэтому я забыл :/   -  person Mooing Duck    schedule 02.10.2014
comment
@MooingDuck Эй, VS2013 поддерживает псевдонимы, а их stdlib реализует псевдонимы C++ 14 type_trait.   -  person bames53    schedule 02.10.2014
comment
@bames53: Да? Я удивляюсь, как я проглядел это!   -  person Mooing Duck    schedule 02.10.2014


Ответы (3)


Я думаю, что то, что у вас есть, вероятно, правильно, но в интересах не писать «ответ», который просто «я согласен», вместо этого я предложу это, которое будет проверять назначение на основе правильных типов - будь то lval, rval, const, что угодно:

template <class String>
auto set_name(String&& name) 
-> decltype(name_ = std::forward<String>(name), void()) {
    name_ = std::forward<String>(name);
}
person Barry    schedule 01.10.2014
comment
Конечно, когда вы снова добавите предложение noexcept, это станет несколько уродливее. - person Ben Voigt; 12.04.2015
comment
Это буквально спасло мне жизнь. Я не мог заставить работать какие-либо is_* type_traits, и использование decltype ускользало от меня. Огромное спасибо! - person Fabio A.; 19.10.2019

Одно из объяснений, которое я рассматривал, заключалось в том, что это простая опечатка, и ограничение должно было быть std::is_same<std::decay_t<String>, std::string>::value вместо !std::is_same<std::decay_t<String>, std::string>::value.

Да, правильное ограничение, представленное на экране, было is_same, а не !is_same. Похоже, на вашем слайде опечатка.


Однако это будет означать, что установщик не работает, например, с const char *

Да, и я считаю, что это было сделано намеренно. Когда строковый литерал, такой как "foo", передается функции, принимающей универсальную ссылку, то выведенный тип не является указателем (поскольку массивы превращаются в указатели только при захвате в параметре шаблона по значению), скорее это const char(&)[N]. Тем не менее, каждый вызов set_name со строковым литералом разной длины будет создавать новую специализацию set_name, например:

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");

Предполагается, что ограничение определяет универсальную ссылку, чтобы оно принимало и выводило только типы std::string для аргументов rvalue или cv-std::string& для аргументов lvalue (поэтому оно std::decayed перед сравнением с std::string в этом std::is_same состоянии).


очевидно, он предназначался для работы с этим типом, учитывая, что это один из случаев, проверенных в презентации.

Я думаю, что протестированная версия (4) не была ограничена (обратите внимание, она называлась String&&+идеальная переадресация), поэтому она могла быть такой простой, как:

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

так что, когда передается строковый литерал, он не создает экземпляр std::string перед вызовом функции, как это делали бы версии без шаблона (ненужное выделение памяти в коде вызываемого только для создания временного std::string, который в конечном итоге будет перемещен в, возможно, предварительно выделенный пункт назначения, например _name):

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

Мне кажется, что правильное ограничение больше похоже на: std::enable_if_t<std::is_assignable<decltype((name_)), String>::value>>

Нет, как я уже писал, я не думаю, что целью было ограничить set_name принятием типов, назначаемых для std::string. Еще раз - этот оригинальный std::enable_if должен иметь единственную реализацию функции set_name, принимающую универсальную ссылку, принимающую только значения std::string rvalue и lvalue (и ничего больше, хотя это шаблон). В вашей версии std::enable_if передача чего-либо, не назначаемого std::string, приведет к ошибке независимо от того, является константа или нет при попытке сделать это. Обратите внимание, что в конечном итоге мы можем просто переместить этот аргумент name в _name, если это неконстантная ссылка на значение, поэтому проверка присваиваемости бессмысленна, за исключением ситуаций, когда мы не используем SFINAE для исключения этой функции из разрешения перегрузки. в пользу других перегрузок.


Каково правильное ограничение enable_if для идеального установщика переадресации?

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

или вообще без ограничений, если это не приведет к снижению производительности (точно так же, как передача строковых литералов).

person Piotr Skotnicki    schedule 01.10.2014
comment
Я не думаю, что цель состояла в том, чтобы ограничить set_name для принятия типов, назначаемых std::string Если мы сравним его с другими решениями, оно должно быть по крайней мере неявно преобразовано в std::string, чтобы разрешить те же преобразования, что и другие решения. Но это кажется неуместным, поскольку мы не хотим выполнять это неявное преобразование перед присваиванием. - person dyp; 02.10.2014
comment
@dyp: это веская причина, по которой тип ограничен только std::strings, а не типами, назначаемыми для строки, вы это имеете в виду? - person Piotr Skotnicki; 02.10.2014
comment
Ну, я имею в виду, что другие решения позволяют x.set_name("hello");, тогда как это не позволяет - "hello" не является std::string, но неявно преобразуется в std::string&&, std::string const& и std::string. - person dyp; 02.10.2014
comment
Судя по видео, оно было представлено как !is_same, а из обсуждения Херба я уверен, что он явно хотел поддерживать строковые литералы, хотя вы поднимаете хороший вопрос об инициировании шаблона для каждой длины используемого строкового литерала. -- Кроме того, я упомянул, что это ограничение можно просто опустить, так как мы не используем перегрузку, но я предложил замену, поскольку Херб утверждал, что идеальные серверы пересылки должны быть ограничены. Кроме того, некоторые компиляторы понимают enable_if и используют его для улучшения диагностики. - person bames53; 02.10.2014
comment
@bames53: не смотри на слайд в видео, смотри на большой экран позади Херба (я вижу там is_same). и он даже спрашивает, поддерживает ли он строковые литералы (аудитория говорит, что нет) и почему нет (почему он неправильно ограничен) - по крайней мере, это то, что я вижу и слышу. Я думаю, что добавление ограничения is_assignable даже не улучшит диагностику, ошибка в конечном итоге будет вызвана, если тип выводимого аргумента не назначаемый копированием / назначаемый перемещением на std::string - person Piotr Skotnicki; 02.10.2014
comment
@dyp вы можете ограничить шаблон с помощью is_convertible_t<>, однако это может вызвать проблему в случае, когда тип является преобразуемым, но какое бы преобразование/разрешение перегрузки ни происходило, фактически не использует этот путь преобразования. например. is_assignable проверит именно те преобразования, которые действительно используются, но отложит эти преобразования до тех пор, пока они не потребуются в контексте set_name. - person bames53; 02.10.2014
comment
@ bames53 Вот почему я сказал, что это кажется неуместным - чтобы использовать просто гарантию is_convertible, нужно было записать задание как _name = std::string( std::forward<String>(name) );, что противоречит цели версии String&&. - person dyp; 02.10.2014
comment
@ПиотрС. Ах, вы правы насчет того, что слайды разные. Моя интерпретация всего, что он не принимает строковые литералы, потому что он неправильно ограничен, заключается в следующем: Херб хочет, чтобы он принимал строковые литералы, и считает, что он правильно ограничен, чтобы разрешить их, и что, если он неправильно ограничен и не допускает их, тогда это Виноват Говард. Я никогда не слышал явного заявления от Херба о том, что он намерен запретить строковые литералы, а в его тесте явно используется char*. Я посмотрю, смогу ли я привлечь внимание Херба, чтобы прояснить это. - person bames53; 02.10.2014
comment
@bames53: Я сомневаюсь, что Херб использовал ограниченную версию для тестирования с const char*. Мне кажется, что он протестировал только String&& без ограничений, как я написал в своем ответе, но было бы здорово уточнить это на 100%. - person Piotr Skotnicki; 02.10.2014
comment
@ bames53 Я думал, что Херб сардонически спросил что-то вроде «Это неправильно ограничено?», потому что он хотел сделать более широкое замечание о том, как избежать сложности. Он имел в виду, что (независимо от того, правильное ограничение или нет), если мы не можем быть уверены, что Говард Хиннант понял его правильно, то мы, возможно, не должны доверять себе, чтобы сделать это в подавляющем большинстве случаев. - person Tim Seguine; 10.10.2014
comment
@ bames53 Да, я согласен с тем, что он спросил в первую очередь, но я также думаю, что он намеревался показать правильное ограничение. - person bames53; 10.10.2014

Я попытался уместить эти мысли в комментарий, но они не уместились. Осмелюсь написать это, так как меня упоминают как в комментариях выше, так и в замечательном выступлении Херба.

Прости, что опоздал на вечеринку. Я просмотрел свои записи и действительно виновен в том, что рекомендовал Хербу исходное ограничение для варианта 4 за вычетом ошибочного !. Никто не идеален, и моя жена это с уверенностью подтвердит, и меньше всего я. :-)

Напоминание: точка зрения Херба (с которой я согласен) заключается в том, чтобы начать с простого совета C++98/03.

set_name(const string& name);

и двигаться оттуда только по мере необходимости. Вариант № 4 — это совсем немного движения. Если мы рассматриваем вариант № 4, мы вынуждены подсчитывать загрузки, хранилища и выделения в критической части приложения. Нам нужно назначить name на name_ как можно быстрее. Если мы здесь, читабельность кода гораздо менее важна, чем производительность, хотя корректность по-прежнему важнее.

Предоставление вообще никаких ограничений (для варианта № 4) я считаю немного неправильным. Если какой-то другой код попытается ограничить себя тем, может ли он вызвать employee::set_name, он может получить неверный ответ, если set_name вообще не ограничен:

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));
    // ...

Если set_name не имеет ограничений, а String выводит какой-то совершенно несвязанный тип X, приведенное выше ограничение для foo неправильно включает этот экземпляр foo в набор перегрузки. И правильность по-прежнему царит...

Что, если мы хотим присвоить name_ один символ? Произнесите A. Должно ли это быть разрешено? Должно ли это быть злым быстро?

e.set_name('A');

А почему бы не?! std::string имеет именно такой оператор присваивания:

basic_string& operator=(value_type c);

Но обратите внимание, что нет соответствующего конструктора:

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

Следовательно, is_convertible<char, string>{} равно false, а is_assignable<string, char>{} равно true.

Попытка установить имя string с помощью char не является логической ошибкой (если только вы не хотите добавить документацию к employee, которая говорит об этом). Таким образом, несмотря на то, что исходная реализация C++98/03 не позволяла использовать синтаксис:

e.set_name('A');

Это позволило выполнить ту же логическую операцию менее эффективным образом:

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

И мы имеем дело с вариантом № 4, потому что мы отчаянно пытаемся оптимизировать эту вещь в максимально возможной степени.

По этим причинам я думаю, что is_assignable — лучший признак для ограничения этой функции. И стилистически я нахожу метод Барри для написания этого ограничения вполне приемлемым. Поэтому здесь мой голос.

Также обратите внимание, что employee и std::string здесь являются лишь примерами в выступлении Херба. Они заменяют типы, с которыми вы имеете дело в вашем коде. Этот совет предназначен для обобщения кода, с которым вам приходится иметь дело.

person Howard Hinnant    schedule 12.04.2015
comment
Это хороший момент в отношении необходимости ограничения для предотвращения назначения int на name_, однако мне кажется, что это особый случай из-за того, что применяется неявное преобразование из int в char. В идеале строковый класс мог бы запретить неявные преобразования в этом случае, но я думаю, что по крайней мере мы можем что-то сделать в employee::set_name. Как насчет этого? Или, может быть, использование фигурных скобок для этого слишком тонко и поэтому так же необучаемо, как и ограничения SFINAE? - person bames53; 12.04.2015
comment
@bames53: Моя ошибка в использовании int в качестве примера другого типа в моем примере. Я должен был использовать class rutabaga и продемонстрировать, что rutabaga не имеет преобразований в std::string. Однако иногда employee поглощают rutabaga, поэтому вполне возможно, что foo перегружается на employee и rutabaga, или что-то, что rutabaga неявно преобразуется в vegetable, или шаблонный параметр, представляющий vegetable. т.е. с расширенным SFINAE в качестве ограничения можно использовать любое выражение, даже e.set_name. - person Howard Hinnant; 12.04.2015
comment
Но если вы ограничите с помощью is_assignable, ваш foo с String = int все равно будет в наборе перегрузки, потому что intdouble и т. д.) можно преобразовать в char. - person T.C.; 13.04.2015