Правильные setters на современном C++
В старом добром C++03 были довольно простые правила передачи параметров в функции, поэтому никаких вопросов с написанием сеттеров не возникало. Все объекты должны передаваться по константной ссылке, чтобы избежать лишнего копирования, и этот же подход надо было использовать и в сеттерах. Например, типичный сеттер для std::string
выглядел вот так:
class my_class {
public:
void set_name(const std::string & n) {
name_ = n;
}
private:
std::string name_;
};
Вообще говоря, этот код и раньше вызывал некоторые вопросы. Вызов set_name("foo")
со строковым литералом в качестве параметра создает временный объект std::string
, а потом этот объект копируется. Поэтому правильно было бы иметь ещё и второй сеттер, принимающий в качестве параметра строковой литерал. Но обычно делали только один сеттер, ну и как бы проблема касалась вроде бы только std::string
(на самом деле нет), поэтому никто особо не заморачивался.
С появлением C++11 всё сильно изменилось. У нас появилась move semantics и r-value ссылки, и это надо было учитывать. Теперь помимо двух упомянутых ранее сеттеров правильно было бы иметь ещё и третий, принимающий в качестве параметра r-value ссылку:
class my_class {
public:
void set_name(const std::string & n) {
name_ = n;
}
void set_name(const char * n) {
name_ = n;
}
void set_name(std::string && n) {
name_ = std::move(n);
}
private:
std::string name_;
};
В C++17 добрые люди добавили std::string_view
, поэтому теперь требуется уже четыре версии сеттера, но это частная проблема со строками. А проблема с r-value ссылками касается вообще всех классов, а не только std::string
. Тем более, что проблема разрастается в более сложных случаях, когда один сеттер используется для сохранения нескольких значений, и количество вариантов растет экспоненциально.
Помню в какой-то книге по C++14 (вроде бы это была книга от Herb Satter) предлагалось в качестве альтернативного легкого варианта передавать параметр по значению, а потом этот параметр перемещать в нужное место:
void set_name(std::string n) {
name_ = std::move(n);
}
Такой вариант не является самым правильным с точки зрения эффективности, потому что будет создаваться временный объект std::string
, но по крайней мере во всех случаях не будет дополнительного копирования.
Но с последним вариантом есть одна существенная проблема. Он работает без копирований только в том случае, если для типа реализован перемещающий конструктор. В противном случае будет дополнительное копирование вообще всегда, и гораздо лучше бы было передать параметр старым добрым образом через константную ссылку.
Мы точно знаем, что для std::string
реализован правильный перемещающий конструктор, но что если надо сделать сеттер для произвольного пользовательского типа foo
? Каждый раз ходить и смотреть есть ли в нём правильный перемещающий конструктор? Ну и сразу же возникает вопрос что делать в обобщенном коде, работающим с произвольными типами.
В общем примерно понятно, что проблема решается с использованием шаблонов и универсальных ссылок. Вроде бы идея использовать шаблоны для каждого сеттера - это полный ад, но допустим, что мы хотим написать идеальный сеттер без дублирования кода. Первый вариант с использованием шаблонов на C++11 будет выглядеть так:
template <typename Name>
void set_name(Name && n) {
name_ = std::forward<Name>(n);
}
Этот вариант идеален с точки зрения эффективности, но в целом довольно печальный, потому что теперь в set_name
можно передать вообще всё что угодно, и если туда передать что-то неправильное, то компилятор выдаст длинную простыню с сообщением об ошибке с указанием места внутри set_name
, и пользователю придется “разматывать” это сообщение, чтобы найти место с ошибкой в своем коде.
Как этот код улучшить? Ну понятно, что с помощью SFINAE и enable_if
, который в том или ином виде существует уже лет 20, но в C++11 был добавлен в стандарт. Плюс к этому в C++11 была добавлена куча полезных type traits, среди которых есть и нужный нам std::is_assignable
, проверяющий, что один тип можно присвоить в другой.
Поэтому идиоматически правильный сеттер на C++11 будет выглядеть примерно так:
template <typename Name>
typename std::enable_if <
std::is_assignable<std::string, Name>::value
>::type
set_name(Name && n) {
name_ = std::forward<Name>(n);
}
Что бы вы сказали, если бы вам предложили везде в качестве стандартного правила использовать такой код во всех сеттерах? Я бы отнёсся к такому предложению довольно скептично. Но это было десять лет назад во времена C++11. За прошедшие годы в C++ добавили очень много новых вещей, с помощью которых этот код можно сделать сильно лучше. Попробую показать эти изменения постепенно.
В C++14 был добавлен вспомогательный шаблонный alias template std::enable_if_t
, который убирает необходимость использования вложенного в std::enable_if
типа type
и дополнительного ключевого слова typename
. В C++14 этот код будет выглядеть так:
template <typename Name>
std::enable_if_t<std::is_assignable<std::string&, Name>::value>
set_name(Name && n) {
name_ = std::forward<Name>(n);
}
В C++17 добавили inline variables и переменную std::is_assignable_v
, которая убирает необходимость использования вложенного значения value
:
template <typename Name>
std::enable_if_t<std::is_assignable_v<std::string&, Name>>
set_name(Name && n) {
name_ = std::forward<Name>(n);
}
В C++20 добавили много новых вещей, которые позволяют значительно улучшить этот код, но начнем по порядку. Во-первых, теперь есть концепты, поэтому можно использовать ключевое слово requires
вместо мерзкого std::enable_if
:
template <typename Name>
void set_name(Name && n)
requires std::is_assignable_v<std::string&, Name> {
name_ = std::forward<Name>(n);
}
Во-вторых, можно сделать концепт assignable_to
и использовать его прямо в объявлении шаблонного параметра:
template <typename From, typename To>
concept assignable_to = std::is_assignable_v<To, From>;
...
template <assignable_to<std::string&> Name>
void set_name(Name && n) {
name_ = std::forward<Name>(n);
}
В-третьих, в C++20 теперь есть возможность объявлять шаблонные функции с использованием auto
, поэтому доступен вот такой вариант:
void set_name(auto && n) requires assignable_to<decltype(n), std::string&> {
name_ = std::forward<decltype(n)>(n);
}
Ну и наконец, мы можем использовать концепт assignable_to
непосредственно в объявлении параметра с типом auto
:
void set_name(assignable_to<std::string&> auto && n) {
name_ = std::forward<decltype(n)>(n);
}
Вот последний вариант уже действительно похож на то, что можно использовать при написании повседневного кода. Единственный вопрос, который тут возникает, - это отсутствие концепта std::assignable_to
в стандартной библиотеке.
В стандартной библиотеке есть концепт std::assignable_from
, но его не получится использовать в последнем самом коротком варианте из-за неподходящего порядка параметров. Плюс к этому с ним есть дополнительная проблема в том, что по каким-то не очень понятным причинам этот концепт проверяет наличие не только оператора присваивания, но и конструктора, принимающего нужный параметр.
Ещё есть концепт std::convertible_to
, но конверсия и присваиваемость - это не одно и то же. Хотя в большинстве случаев, в том числе и для std::string
, это будет работать:
void set_name(std::convertible_to<std::string> auto && n) {
name_ = std::forward<decltype(n)>(n);
}
В общем думаю попробовать на постоянной основе использовать такой подход для написания сеттеров в повседневном коде. Посмотрим что из этого получится.
comments powered by Disqus