Правильные 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