Effective Modern C++
- Иногда auto выводит не то, что нужно
- Используйте nullptr вместо 0 и NULL
- Используйте alias вместо typedef
- Используйте scoped enums вместо unscoped enums
- Используйте deleted функции вместо private undefined
- Declare overriding functions override.
- Prefer const_terators to iterators
- Make const member functions thread safe
-
Use std::unique_ptr for exclusive-ownership resource management
-
Use std::shared_ptr for shared-ownership resource management
- Use std::weakptr for std::sharedptr-like pointers that can dangle
-
Prefer std::makeunique and std::makeshared to direct use of new
- When using the Pimpl Idiom, define special member function in the implementation file
- Understand std::move and std::forward
- Distinquish universal references from rvalue references
-
Use std::move on rvalue references, std::forward on universal references
- Avoid overloading on universal references
-
Familiarize yourself with alternatives to overloading on universal references
- Understand reference collapsing
- Assume that move operations are not present, not cheap, and not used
- Avoid default capture modes
- Use init capture to move objects into closures
- Use decltype on auto&& parameters to std::forward them
- Prefer lambdas to std::bind
- Prefer task-based programming to thread-based
- Specify std::launch::async if asynchronicity is essential
- Make std::threads unjoinable on all paths
- Be aware of varying thread handle destructor behavior
- Consider void futures for one-shot event communication
- Use std::atomic for concurrency, volatile for special memory
- Consider pass by value for copyable parameters that are cheap to move and always copied
- Consider emplacement instead of insertion
Вывод типов через auto
Тип для auto
выводится так же, как и для шаблонов. В том выводе типов у нас есть определение шаблона и его вызов:
template<typename T>
void f(ParamType param);
f(expr);
Когда переменная объявляется через auto
, то auto
выступает в роли T, а спецификатор типа - как ParamType
. Например:
auto x = 27; // auto -> T, auto -> ParamType
const auto cx = x; // auto -> T, const auto -> ParamType
const auto& rx = x; // auto -> T, const auto& -> ParamType
Для вывода представим соответствующие им шаблоны и их вызовы:
template<typename T>
void func_x(T param);
func_x(27); // ParamTYpe -> int, T -> int
template<typename T>
void func_cx(const T param);
func_cx(x); // ParamType -> const int, T -> int
template<typename T>
void func_rx(const T& param);
func_rx(x); // ParamType -> const int&, T -> int
Во всех остальных случаях логика точно такая же как и для вывода типа шаблона. Но есть одно исключение, о нем дальше.
Особый случай для initializer_list
auto x1 = 27; // int
auto x2(27); // int
auto x3 = {27}; // std::initializer_list<int> = {27}
auto x4{27}; // std::initializer_list<int> = {27}
Так происходит потому что в выводе типов через auto
прописано особое правило: если значение для авто-объявленной переменной заключено в фигурные скобки, то тип ВСЕГДА выводится как std::initializer_list.
При этом в выводе типов для шаблонов такого правила нет и это единственное место где алгоритмы различаются:
template<typename T>
void f(T param);
f({ 11, 23, 9 }); // ОШИБКА КОМПИЛЯЦИИ!
template<typename T>
void f2(std::initializer_list<T> list);
fw({11,23,9}); // все ок, тип T выводится как int
decltype
decltype - это функция, которая принимает переменную, а возвращает ее тип. Может быть использована там, где ожидается указание типа.
const int i = 0; // decltype(i) -> const int
bool f(const Widget&); // decltype(w) -> const Widget&, decltype(f) -> bool(const Widget&)
Обычно используется там, где тип возвращаемого значения зависит от типа аргумента:
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(c[i]) {
authenticateser();
return c[i];
}
здесь auto
не имеет отношения к выводу типов, а лишь указывает, что возвращаемый тип будет указан после списка параметров (trailing return type syntax). Такой синтаксис необходимо использовать, когда тип возвращаемого значения зависит от типов параметров.
В C++14 можно возвращать из функций auto
, не указывая тип после стрелочки, но с этим бывают проблемы, поэтому рекомендуется возвращать decltype(auto)
.
С authAndAccess
осталась одна проблема - она не сможет принимать rvalue для контейнера. Модифицируем так, чтобы мог:
template<typename Container, typename Index>
auto get(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i]) {
authenticateUser();
return std::forward<Container>(c)[i];
}
Теперь для c типа lvalue функция будет возвращать lvalue, а для rvalue - rvalue.
Особенность поведения decltype
Применение decltype к имени переменной возвращает тип этого имени. Однако применение к lvalue, котороя является чем-то более сложным, чем имя, возвращает ссылку на lvalue. То есть decltype над выражением не-именем, имеющим тип T вернет тип T&. Такое поведение редко на что-либо влияет, однако есть интересное следствие:
int x = 0;
decltype(x); // int
decltype((x)); // int&
Как видно, оборачивание значения в скобки может поменять значение, возвращаемое decltype. Это особенно важно в C++14, где можно возвращать из функции decltype(auto)
и случайно можно вернуть ссылку на элемент вместо элемента.
Используйте auto вместо явных определений типов
auto не даст создать неинициализированный объект
int x; // не инициализирован!
auto x; // не скомпилится!
Позволяет не писать сложные типы
template<typename It>
void dwim(It b, It e)
{
while (b != e) {
typename std::iterator_traits<It>::value_type currValue = *b;
auto currValue2 = *b;
...
}
}
Экономить память при использовании функторов
Тип лямбды неизвестен до компиляции, поэтому описать его точно - невозможно. Приходится использовать тип std::function
.
std::function<bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)>
funcs = [](const std::unique_ptr<Widget>& a, const std::unique_ptr<Widget>& b) {
return *a < *b;
}
Но у него есть недостаток - он всегда занимает фиксированный размер в памяти, и если его не хватает, то аллоцирует память в куче. Тогда как реальный тип замыкания, выводимый во время компиляции и используемый с помощью auto всегда занимает ровно столько места, сколько ему требуется.
Плюс к этому, из-за особенностей реализации, при вызове функции через std::function
запрещается инлайнинг и добавляется непрямой вызов функции (indirect function calls), что ухудшает производительность по сравнению с auto.
Неявные приведения типов
Допустим, есть такой код:
std::vector<int> v;
unsigned sz = v.size();
Все бы ничего, да только v.size()
возвращает вовсе не unsigned, а std::vector<int>::size_type
.
Другой пример:
std::unordered_map<std::string, int> m;
for(const std::pair<std::string, int>& p: m) {
...
}
Реальный тип элементов, содержащихся в unordered_map
- std::pair<const std::string, int>
. В результате компилятор не сможет привести std::pair<const std::string, int>
к std::pair<std::string, int>
и будет для каждого члена создавать временный объект, ссылку на который копировать в p. После каждой итерации временный объект будет уничтожен.
Использование auto в этом случае делает код проще и производительнее:
std::unordered_map<std::string, int> m;
for(const auto& p: m) {...}
Иногда auto выводит не то, что нужно
std::vector<bool> features(const Widget& w);
Widget w;
auto highPriority = features(w)[5];
processWidget(w, highPriority);
Как ни удивительно, этот код приводит к undefined behavior. Дело в том, что оператор []
для std::vector<bool>
возвращает std::vector<bool>::reference
. Так происходит потому, что булевого типа у вектора есть специальная реализация, которая хранит по одному биту на элемент. Теперь оператор []
должен возвращать ссылку на бит, но в C++ запрещены ссылки на биты. Поэтому приходится возвращать тип, который ведет себя как bool&
. ЭТо означает, что он, помимо прочего, должен быть неявно приводим к bool
.
Получается, что когда мы делаем bool highPriority = features(w)[5]
, то возвращается std::vector<bool>::reference
, который приводится к bool
и дальше все нормально.
А вот когда мы делаем auto highPriority = features(w)[5]
, то дальше все зависит от реализации типа std::vector<bool>::reference
. Одна из реализаций представляет из себя:
- указатель на машинное слово вектора, в котором содержится интересующий бит
- сдвиг в этом слове
Итак, вызов features
возвращает временный объект-вектор. Оператор []
возвращает std::vector<bool>::reference
, в котором содержится ссылка на элемент внутри временного объекта-вектора. Мы записываем это в highPriority
, после чего временный объект уничтожается, а в highPriority
остается висячая ссылка на уничтоженный объект.
Здесь std::vector<bool>::reference
- это прокси-класс, который не предназначен для того, чтобы жить дольше, чем одно выражение. Поэтому auto очень плохо дружит с прокси-классами.
Такие прокси-классы очень тяжело найти заранее, однако когда стало понятно, что проблема в прокси-классе, не стоит избавляться от auto. Лучший способ - использовать явное приведение типа:
auto highPriority = static_cast<bool>(features(w)[5]);
Различия между () и {} при инициализации объектов
Для начало важно отличать инициализацию от присвоения:
Widget w1; // инициализация, вызван дефолтный конструктор
Widget w2 = w1; // инициализация, вызывается конструктор копирования
w1 = w2; // присвоение, вызывается оператор =
В C++ 11 представлена uniform initialization - предпочтительный способ инициализации:
int x{0};
std::vector<int> v{1,2,3,4,5};
Тот же синтаксис можно использовать и для задания дефолтных значений не-статическим полям классов, наравне с синтаксисом через =:
class Widget {
private:
int x{0}; // ОК
int y = 0; // тоже ок
int z(0); // ошибка компиляции!
}
Однако если инициализируем не-копируемый тип, то {} валиден наравне с синтаксисом через ():
std::atomic<int>ai1{0}; // OK
std::atomic<int>ai2(0); // OK
std::atomic<int>ai3 = 0; // ошибка!
Поэтому, чтобы не путаться, лучше всегда использовать универсальную (uniform) инициализацию вида int x{0};
.
{} запрещает преобразование типа с потерей точности
double x,y,z;
int sum1{ x+y+z }; // не скомпилится, так как сумма даблов может быть невыразима через int
int sum2( x+y+z ); // спокойно компилится и приводит к неожиданному поведению при выполнении
int sum3 = x + y + z; // аналогично sum2
{} не подвержена most vexing parse
В С++ есть такое правило - все, что может быть интерпретировано как объявелние, должно быть интерпретировано как объявление.
Поэтому часто, когда хотим инициализировать переменную дефолтным конструктором через скобки, вместо этого получается объявление функции:
Widget w1(10); // когда у конструктора есть параметры, то все норм
Widget w2(); // а вот когда хотис использовать дефолтный конструктор, то получается объявление функции
С использованием {} код выглядит так и не содержит этой проблемы:
Widget w3{};
Недостаток: опять проблемы с initializer_list
Если при инициализации через {} имеется конструктор, принимающий initializer_list
, и он может быть теоретически использован, то будет использован именно он:
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
};
Widget w1(10, true); // будет вызван первый конструктор
Widget w2{10, true}; // 3-й
Widget w3(10, 5.0); // 2-й
Widget w4{10, 5.0}; // 3-й
Причем иногда путь довольно непрост:
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
operator float() const; // оператор преобразования Widget во float
// конструктор копирования
// конструктор перемещения
... };
Widget w5(w4); // вызывается конструктор копирования
Widget w6{w4}; // вызывается преобразование к float и затем конструктор с initializer_list, так как float может быть преобразован к long double
Widget w7(std::move(w4)); // конструктор перемещения
Widget w8{std::move(w4)}; // опять конструктор с initializer_list через преобразование к float
Более того, компилятор настолько сильно хочет использовать initializer_list
, что даже идеально подходящие другие конструкторы ему не помеха:
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<bool> il);
...
};
Widget w{10, 5.0}; // ошибка компиляции!
Компилятор, несмотря на то, что есть конструктор, принимающий int и double, опять попытался использовать конструктор с initializer_list
и не смог, потому что для этого требуется сужающее приведение int и double к bool, а сужающие приведения запрещены в инициализации через {}.
Однако и из этого правила есть исключение - если есть дефолтный конструктор и мы вызываем инициализацию без параметров, то дефолтный конструктор имеет высший приоритет над конструктором с initializer_list
:
class Widget {
public:
Widget();
Widget(std::initializer_list<int> il);
... };
Widget w1; // дефолтный конструктор
Widget w2{}; // дефолтный конструктор
Widget w3(); // most vexing parse! объявляет функцию
Widget w4({}); // только так мы в этом случае можем вызвать конструктор с initializer_list
Widget w5{{}}; // ну или так
Один из выводов из всего этого - если вы автор библиотеки, то не стоит добавлять конструктор, принимающий initializer_list
, так как тогда возможно клиенты не смогут использовать ваши остальные конструкторы.
Используйте nullptr вместо 0 и NULL
В С++98 использование 0 и NULL приводило к тому, что перегрузки, принимающие указатель, могли не вызываться:
void f(int);
void f(bool);
void f(void*);
f(0); // f(int)
f(NULL); // могло не скомпилиться, но если компилилось, то вызывало f(int)
Все потому, что 0 - это целочисленный тип и NULL часто был определен тоже как численный тип.
Преимущество nullptr - он не может быть интерпретирован как численный тип, только как указатель. Тип nullptr - std::nullptr_t
. ЭТот тип неявно приводит к себе указатели всех типов, поэтому nullptr - универсальный указатель.
f(nullptr); // f(void*)
Используйте alias вместо typedef
В С++98 были typedef:
typedef
std::unique_ptr<std::unordered_map<std::string, std::string>>
UPtrMapSS;
Они устарели, когда в C++11 появились алиасы:
using UPtrMapSS =
std::unique_ptr<std::unordered_map<std::string, std::string>>;
Еще один пример, демонстрирующий повышенную читаемость алиасов по сравнению с тайпдефом:
typedef void (*FP)(int, const std::string&);
using FP = void (*)(int, const std::string&);
Основное преимущество - алиасы могут быть шаблонизированы, а тайпдефы - нет. В С++98 приходилось извращаться и определять тайпдефы внутри шаблонизированных структур:
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<Widget>::type lw;
Если же после этого мы захотим использовать этот шаблно внутри другого шаблонизированного класса, то придется писать typename
:
template<typename T>
class Widget {
private:
typename MyAllocList<T>::type list;
... };
Кстати, при использовании type traits так и приходится писать, так как они были реализованы с использованием тайпдефов, а не алиасов, несмотря на то, что были введены в C++11. В С++14 признали эту ошибку и для каждого класса std::transformation<T>::type
теперь есть соответствующий std::transformation_t<T>
, реализованный через алиасы.
Так вот, в С++11 есть алиасы и использовать их мы можем так:
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
MyAllocList<Widget> lw;
Используйте scoped enums вместо unscoped enums
Есть такое правило, что имя, объявленное внутри фигурных скобок, видно только внутри области, ограниченной этими скобками. Это правило соблюдается всегда, кроме енумов в C++98.
Поэтому енумы в C++98 - unscoped enums:
enum Color {red, black, white};
auto white = false; // ОШИБКА КОМПИЛЯЦИИ! white уже определен
В С++11 им на замену пришли scoped enums:
enum class Color {red, black, white};
auto white = false; // все норм
Color c = white; // ОШИБКА КОМПИЛЯЦИИ! нет имени white в текущем скоупе
Color c = Color::white; // ok
auto c = Color::white; // ok
Помимо ограниченной видимости, вторая причина, по которой стоит использовать scoped enums - более строгая типизация. Unscoped enums свободно неявно приводятся к целочисленным типам и типам с плавающей точкой:
Color c = Color::red;
if(c < 14.5) { // ОШИБКА КОМПИЛЯЦИИ
...
}
if(static_vast<double>(c) < 14.5) { // OK
...
}
И еще одно преимущество - при использовании scoped enums не нужно перекомпиливать клиентов енума при добавлении в него нового значения. А с unscoped enums - нужно.
Дефолтный тип для scoped enums - int, для unscoped enums - нет дефолтного.
Используйте deleted функции вместо private undefined
В С++98 когда нужно запретить вызов какой-либо функции (обычно конструктора присваивания или копирования), то определяют ее как private и просто не пишут ее реализацию:
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
...
private:
basic_ios(const basic_ios& ); // not defined
basic_ios& operator=(const basic_ios&); // not defined
};
private гарантирует, что внешний код не имеет доступ к этим функциям. Отсутствие реализации для таких функций гарантирует, что даже если какой-то какой-то код, имеющий к ним доступ, попытается их вызвать, он получит ошибку на этапе линковки.
В C++11 такие функции определяются как удаленные:
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
...
basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;
...
}
Удаленные функции никак не могут быть использованы и такие попытки приведт к ошибкам на этапе компиляции.
А еще, используя удаленные функции, можно запретить использование шаблонных функций с определенными типами:
template<typename T>
void processPointer(T* ptr);
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;
template<>
void processPointer<const void>(const void*) = delete;
template<>
void processPointer<const char>(const char*) = delete;
Declare overriding functions override.
Для переопределения виртуальной функции должны совпадать: названия функций, типы аргументов, константность функций, квалификаторы ссылок.
Должны быть совместимыми: возвращаемый тип и exception specification.
Помимо этого, конечно же, метоб в базовом классе должен быть обозначен как virtual.
ПРИМЕЧАНИЕ: Квалификаторы ссылок это такие вот штуки:
class Widget {
public:
...
void doWork() &; // может быть вызвана, только когда *this - lvalue
void doWork() &&; // *this - rvalue
};
w.doWork(); // вызовется первый
makeWidget.doWork(); // вызовется второй
Так вот, все эти ограничения означают, что очень легко допустить ошибку при использовании виртуальных функций. Если ограничения не соблюдены, то код скомпилиться, но в наследнике вместо переопределения будет создана новая функция.
В C++11 для решения этой проблемы появилось ключевое слово override
.
Prefer const_terators to iterators
В C++11 добавились функции cbegin
и cend
, которые возвращают const_iterator
даже для не-константных контейнеров.
std::vector<int> values;
auto it = std::find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1998);
Единственный недостаток у таких функций - в C++11 у них нет non-member версий, как у begin
и end
. В С++14 - уже есть.
В generic-коде лучше использовать non-member версии.
Declare functions noexcept if they won't emit exceptions
- вызывающие функции могут проверять наличие
noexcept
у функции и использовать более безопасный/производительный код
Например, при push_back
в std::vector
, если длина превышает вместимость, то создается новый вектор побольше и все элементы копируются туда. Затем, когда все успешно скопировались, старый вектор уничтожается. Это нужно для защиты от исключений - если на копировании N-го элемента возникнет исключение, то старый вектор останется неизменным.
Но если имеется конструктор перемещения и он помечен как noexcept
, то в этом алгоритме может быть использовано перемещение вместо копирования, ведь мы точно знаем, что исключения на N-м элементе возникнуть не может.
- компилятор генерирует более производительный код, потому что может не генерировать код разматывания стека при исключении для функций, помеченных как
noexcept
.
Вторая причина очень важна. Оптимизаторам не нужно хранить где-то заранее размотанный стэк на случай исключения, не нужно гарантировать, что объекты в noexcept
функции будут уничтожены в обратном порядке создания в случае исключения.
Однако в этом случае, если в функции произойдет исключение, и оно покинет функцию, то моментально будет вызван std::terminate
Условный noexcept
template <class T, size_t N>
void swap(T (&a)[N],
T (&b)[N]) noexcept(noexcept(swap(*a, *b)));
template <class T1, class T2>
struct pair {
...
void swap(pair& p) noexcept(noexcept(swap(first, p.first)) && noexcept(swap(second, p.second)));
};
Здесь noexcept-ность нашей функции обмена зависит от noexcept-ности обмена внутренних элементов - первого элемента массива, либо обоих элементов структуры pair.
Use constexpr whenever possible
constexpr objects
Когда применяется к объектам, constexpr
определяет значение, которое не только константно, но еще и известно во время компиляции.
ТАкие значения могут быть помещены в read-only память. Могут быть применены там, где требуется integral constant expression, например, в качестве длин массивов, аргументов шаблонов, значений енумов, спецификаторов выравнивания.
constexpr functions
Когда применяется к функциям, то все сложнее. Такие функции возвращают константы времени компиляции только когда их аргументами являются константы времени компиляции. В остальных случаях они возвращают обычные значения и работают в рантайме.
Преимущество в том, что не нужны 2 разных функции, одна из которых работает в компайл-тайме, а другая в рантайме.
Ограничения constexpr-функции:
- В C++11 должна содержать не больше одного выражения. Однако оно может быть сколь угодно сложным. Вместо if-else можно использовать "?:", а вместо циклов - рекурсию. В С++14 такого ограничения нет.
- Может принимать и возвращать только типы-литералы, то есть типы, чьи значения определены на этапе компиляции. В C++11 это все типы, кроме
void
. Пользовательские типы могут быть литералами, когда коструктор и все используемые функции определены какconstexpr
.
Make const member functions thread safe
Члены класса, помеченные, как mutable
, могут быть изменены const-функциями. В этом случае только разработчик ответственнен за то, что изменение mutable-поля не разрушит константность функции.
mutable-поля могут быть полезны, например, при реализации кэширования в константной функции.
Пользователь const-функции не знает, используются ли внутри mutable переменные, поэтому он всегда предполагает, что такая функция потокобезопасна.
Поэтому при использовании mutable-полей, нужно обеспечить потокобезопасность для своей функции, например, через std::atomic
, или мютексы, если таких полей несколько.
При использовании мютекса, конечно, его придется тоже сделать mutable
.
Пример:
class Point {
public:
...
double distanceFromOrigin() const noexcept
{
++callCount;
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};
Understand special member function generation
В C++98 автогененерировались:
- дефолтный конструктор:
Widget()
- деструктор:
~Widget()
- конструктор копирования:
Widget(const Widget&)
- оператор присваивания копии:
Widget& operator=(const Widget&)
Они генерировались только, если реально использовались в коде. Все эти фунции генерируются как public inline
. Все, кроме деструктора - не виртуальные. Деструктор генерируется как виртуальный, когда это деструктор в наследованном классе, а в родительском он виртуальный.
В С++11 в списку автогенерируемых функций добавились:
- конструктор перемещения:
Widget(Widget&& rhs)
- оператор присваивания перемещением:
Widget& operator=(Widget&& rhs)
Эти операции так же генерируются только, если нужны, и их дефолтные реализации осуществляют memberwise move не-статических членов класса. То есть конструктор перемещения вызывает конструктор перемещения для всхе не-статических членов, передавая туда соответствующие члены из rhs
, а оператор присваивания перемещением аналогичным образом присваивает членам lhs
соответствующие члены rhs
.
Конструктор перемещения также конструирует перемещением все члены базовых классов, а оператор присваивания перемещением присваивает их перемещением.
Когда речь идет о конструировании перемещением, или присваивании перемением, то это еще не значит, что перемещение обязательно произойдет. На самом деле будет выполнен "запрос перемещения", потому что не-перемещаемые типы будут просто скопированы. Внутри каждого конструктора перемещения и присваивания перемещением все равно находится обычный std::move
, который полагается на наличие соответствующих функций.
Генерация копирования и перемещения
Две операции копирования генерируются независимо друг от друга. То есть, если пользователь объявил конструктор копирования, но не объявил присваивание копированием, а потом написал код, который требует присваивание копированием, то оператор присваивания копированием будет сгенерирован.
Операции перемещения, напротив, генерируются зависимо. Если пользователь определил одну из них, это не дает компилятору сгенерировать другую. Мотивация такова - если пользовать определил, например, конструктор перемещением, значит, есть что-то, что его не устраивает в дефолтной memberwise-реализации перемещения. А значит оператор присваивания перемещением тоже будет сгенерирован неправильно.
Более того, операции перемещения не генерируются, если определена хотя бы одна операция копирования. Мотивация такая же - если явно определена операция копирования, значит дефолтная не устраивает, значит дефолтная memberwise-реализация перемещения, скорее всего, тоже будет ошибочна.
В обратную сторону работает точно так же. Операции копирования не генерируются, если определена хотя бы одна операция перемещения.
Помимо этого, операции перемещения не генерируются, если определен деструктор, потому что наличие деструктора обычно говорит о том, что здесь происходит какой-то менеджмент ресурсов, а в этом случае при копировании нужно тоже что-то с ресурсом сделать. См. Правило Трех.
Итак, операции перемещения генерируются только когда в классе соблюдаются следующие правила:
- Не определена ни одна операция копирования
- Не определена ни одна операция перемещения
- Не определен деструктор
Последнее правило может привести к серьезному ухудшению производительности при простом добавлении деструктора в класс, так как операции перемещения перестанут генерироваться и класс будет всегда копироваться. Поэтому при указании деструктора нужно всегда обязательно добавлять операции перемещения и копирования, даже если они реализуются через =default
.
Итак, окончательный список автогенерируемых функций таков:
Операция | Сигнатура | Правило автогенерации |
---|---|---|
Дефолтный конструктор | Widget() |
Только если в классе не определено никаких конструкторов |
Деструктор | ~Widget() |
По умолчанию noexcept . Виртуальный, если деструктор родительского класса тоже виртуальный |
Конструктор копирования | Widget(const Widget&) |
memberwise-копирование не-статических членов. Только, если не определен явно. Удаляется, если определена операция перемещения. Не рекомендуется использовать автосгенерируемый, если в классе есть оператор присваивания копированием, или деструктор. |
Оператор присваивания копированием | Widget& operator=(const Widget&) |
memberwise-копирование не-статических членов. Только если не определен явно. Удаляется, если определена операция перемещения. Не рекомендуется использовать автосгенерируемый, если в классе есть конструктор копирования, или деструктор. |
Конструктор перемещения | Widget(Widget&& rhs ) |
memberwise-перемещение не-статических членов. Только если в классе не определена ни одна операция копирования, перемещения, или деструктор. |
Оператор присваивания перемещением | Widget& operator=(Widget&& rhs) |
Аналогично конструктору перемещения |
Use std::unique_ptr for exclusive-ownership resource management
Имеют такой же размер, как и сырые указатели (если не используются кастомные делетеры). Для большинства операций генерируют те же инструкции.
Перемещение std::unique_ptr
перемещает владение от пойнтера-источника к пойнтеру-назначению. Пойнтер-источник при этом выставляется в null.
Копирование std::unique_ptr
запрещено.
Типичный кейс - тип возвращаемого значения для фабричной функции.
Может быть задан специфичный делетер:
auto delInvmt = [](Investment* pInvestment) {
makeLogEntry(pInvestment);
delete pInvestment;
};
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
Кастомные делетеры увеличивают размер пойнтера.
Если делетер указан в виде указателя на функцию, то размер пойнтера увеличивается с 1 слова до 2.
Если же это функтор, то дельта размера зависит от того, сколько состояния хранится в функторе. Функторы без состояния (например, лямбды без захваченных переменных) не увеличивают размер вообще.
Поэтому когда кастомный делетер может быть реализован как функция, либо как лямбда без состояния, лучше выбирать лямбду.
std::unique_ptr<T[]>
Для массивов используется вторая форма unique_ptr
. Благодаря этому, всегда однозначно известно, на какой тип сущности указывает пойнтер.
Для сингл-формы не определен оператор индексации, а мульти-форма не имеет оператора разыменования.
std::uniqueptr -> std::sharedptr
std::unique_ptr
легко и эффективно конвертируется в std::shared_ptr
:
std::shared_ptr<Investment> sp = makeInvestment();
std::unique_ptr<Investment> makeInvestment() {
...
}
Поэтому клиент фабрики может сам решать, какая модель владения ему нужна.
Use std::shared_ptr for shared-ownership resource management
У каждого shared_ptr
есть счетчик ссылок. Это конечно влияет на производительность:
- размер
std::shared_ptr
в 2 раза больше размера сырого указателя - память для счетчика ссылок аллоцируется динамически. Объект, на который ссылается указатель, ничего о счетчике не знает, поэтому счетчик должен храниться вне его. Использование
std::make_shared
избегает оверхеда динамической аллокации. - инкременты и декременты счетчика ссылок атомарны, потому что могут быть одновременные читатели и писатели в разных потоках. Поэтому операции увеличения и уменьшения сетчика ссылок довольно медленные.
При перемещении std::shared_ptr
счетчик ссылок остается неизменным, поэтому перемещение таких указателей быстрее, чем копирование.
std::shared_ptr
тоже поддерживает кастомные делетеры, но у них тип делетера не является частью типа указателя:
auto loggingDel = [](Widget *pw) { ... }
std::shared_ptr<Widget> spw(new Widget, loggingDel)
Благодаря этому указатели с разными типами делетеров могут быть помещены в одну коллекцию, переданы в одну и ту же функцию и быть приводимы один к другому.
Еще одно отличие от std::unique_ptr
- указание кастомного делетера не увеличивает размер указателя. Размер всегда равняется двум обычным указателям. На самом деле, память под делетер выделяется, но она просто не является частью указателя. Дело в том, что второй указатель в std::shared_ptr
- указатель не просто на счетчик ссылок, а на так называетмый управляющий блок - control block. Такой блок есть для каждого объекта, управляемого std::shared_ptr
. Этот блок содержит:
- счетчик ссылок
- копию кастомного делетера
- копию кастомного аллокатора, если указан
- вторичный счетчик ссылок, используемый для
std::weak_ptr
.
Управляющий блок создается функцией, которая создает первый std::shared_ptr
на объект. Но так как в момент создания невозможно узнать, является ли этот указатель первым, то происходит следующее:
std::make_shared
всегда создает управляющий блок.- управляющий блок создается, когда
std::shared_ptr
конструируется изstd::unique_ptr
илиstd::auto_ptr
. Это возможно, потому что эти указатели не используют контрольных блоков, так что созданный точно будет первым. - управляющий блок создается, когда
std::shared_ptr
создается из сырого указателя.
Получается, что управляющий блок не создается, когда std::shared_ptr
создается из другого std::shared_ptr
или std::weak_ptr
.
Следствием этих правил является то, что если мы создаем больше одного std::shared_ptr
из одного сырого указателя, то получаем гарантированное undefined behavior, потому что объект будет иметь несколько контрольных блоков, что значит несколько счетчиков ссылок, что значит он будет уничтожен несколько раз.
Отсюда следует правило: не передавать сырые указатели в конструктор std::shared_ptr
. Вместо этого следует использовать std::make_shared
:
std::shared_ptr<Widget> spw1 = std::make_shared<Widget>();
Однако это невозможно, если нужно указать кастомый делетер. В таком случае сырой указатель можно передать, но нужно удостовериться, что он передается как rvalue и не сохраняется ни в какую переменную, что увеличило бы риск повторного создания std::shared_ptr
из этой переменной:
std::shared_ptr<Widget> spw2(new Widget, loggingDel);
sharedfromthis
std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget {
public:
...
void process() {
...
processedWidgets.emplace_back(this);
}
}
Это очень опасный код. Опасен он тем, что при вызове emplace_back
в вектор кладется не shared_ptr
, а сырой указательthis
. Он будет приведен к shared_ptr
, а значит, будет создан новый управляющий блок на this
. Если есть еще какие-нибудь std::shared_ptr
, ссылающиеся на наш объект, то рано или поздно это приведет к undefined behavior.
Выход - использовать std::enable_shared_from_this
:
std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget: public std::enable_shared_from_this<Widget> {
public:
...
void process() {
...
processedWidgets.emplace_back(shared_from_this());
}
}
Функция shared_from_this
обращается к управляющему блоку, ассоциированному с текущим объектом и выбросит исключение, если такового нет. Поэтому обычно наследники std::enable_shared_from_this
закрывают конструктор и делают фабричную функцию, возвращающую std::shared_ptr
:
class Widget: public std::enable_shared_from_this<Widget> {
public:
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
...
void process();
...
private:
Widget();
}
std::shared_ptr<T[]>
Перегрузки для массивов - нет, в отличие от std::unique_ptr
. И не стоит пытаться передавать туда массив. Во-первых, std::shared_ptr
не поддерживает оператор []. Во-вторых, std::shared_ptr
поддерживает конвертацию devived-to-base, которая работает для отдельных объектов, но плохо будет работать с массивами.
Use std::weakptr for std::sharedptr-like pointers that can dangle
Иногда бывает нужно использовать указатель, который ведет тебя как shared_ptr
, но при этом не участвует в подсчете ссылок. ТАкой указатель есть - это std::weak_ptr
. Он знает, когда он указывает в пустоту, то есть когда объект, на который он указывает, больше не существует.
На самом деле std::weak_ptr
не является умным указателем, это улучшение над std::shared_ptr
. Поэтому std::weak_ptr
нельзя разыменовать и нельзя проверить его на равенство null.
std::weak_ptr
обычно конструируются из std::shared_ptr
:
auto spw = std::make_shared<Widget>();
std::weak_ptr<Widget> wpw(spw);
spw = nullptr;
if(wpw.expired()) {
printf("weak_ptr is dangling now");
}
Операции разыменования нет по причине того, что нельзя разделять операцию проверки на expired
и операцию разыменования - ведь объект может уничтожиться между этими вызовами. Поэтому есть атомарная операция, объединяющая эти две:
std::shared_ptr<Widget> spw1 = wpw.lock();
auto spw2 = wpw.lock();
if(spw2) {
...
}
Если объект заэкспайрился, то lock()
вернет null.
Второй вариант - создать shared_ptr
из weak_ptr
:
std::shared_ptr<Widget> spw3(wpw);
std::weak_ptr
полезен при реализации кэширования - в этом случае кэширующий указатель стоит реализовать в виде weak_ptr
.
Еще его используют при реализации паттерна Observer. В нем наблюдатели держат копию субъекта таким std::weak_ptr
, чтобы не расширять его лайфтайм.
Ну и наконец третий кейс - циклические ссылки. При использовании shared_ptr
мы получили бы утечку памяти, а если одну из ссылок сделать weak_ptr
, то все хорошо.
Размер у std::weak_ptr
- такой же, как у shared_ptr
, они используют те же управляющие блоки. Операции создания, уничтожения и присваивания изменяют вторичный счетчик ссылок в управляющем блоке.
Prefer std::makeunique and std::makeshared to direct use of new
Рассмотрим код:
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
Как ни странно, этот код может привести к утечке памяти. Это происходит, если операции располагаются при компиляции следующим образом:
new Widget()
computePriority()
std::shared_ptr()
Если на 2 шаге наша функция выстрелит исключение, то мы получим зависший new Widget
, на который никто не указывает - получили утечку.
Другая причина - эффективность. Посмотрим на код:
std::shared_ptr<Widget> spw(new Widget);
Здесь производится не одна, а две динамических аллокации. Сначала создается Widget
, а потом управляющий блок для него.
Если же мы используем make_shared
:
auto spw = std::make_shared<Widget>();
то осуществляется лишь одна аллокация, потому что make_shared
аллоцирует один кусок памяти и для Widget
и для его управляющего блока. Помимо того, уменьшается и размер программы, потому что становится меньше инструкций аллоцирования.
Ограничения
make
-функции не позволяют указать кастомный делетер- есть традиционная путаница с
initializer_list
:
auto upv = std::make_unique<std::vector<int>>(10, 20);
Здесь будет создан вектор с 10 элементами, каждый из которых равен 20. А значит внутри для инициализации используется синтаксис с круглыми скобками, а не с фигурными.
- контрольный блок должен существовать, пока не будет уничтоен последний
shared_ptr
, ссылающийся на него И последнийweak_ptr
, ссылающийся на него. А так какmake_shared
аллоцирует один кусок памяти для контрольного блока и для объекта, то и память, выделенная для объекта не может быть освобождена, пока не уничтожится последнийstd::weak_ptr
. То есть объект давно уничтожился, но он продолжает занимать память. В случае использованияnew
память освободится, как только объект уничтожится, независимо от наличияweak_ptr
, ссылающися на него.
When using the Pimpl Idiom, define special member function in the implementation file
Pimpl Idiom - техника, используемая для уменьшения времени билда. Члены класса заменяются на указатель на класс/структуру реализации, члены класса перемещаются в класс реализации и обращение к ним осуществляется через указатель.
До применения:
// Widget.h
#include "Gadget.h"
#include <string>
#include <vector>
class Widget {
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Клиенты Widget
должны включать <string>
, <vector>
и gadget.h
. Главная проблема здесь в том, что если заголовок gadget.h
меняется, то все клиенты должны перестроиться.
После применения для С++98:
// Widget.h
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl;
Impl *pImpl;
}
Структура Widget::Impl
определена где-то в другом файле. Здесь мы видим неполный тип. С таким типом можно сделать очень мало вещей, и одна из них - определение указателя.
Вторая часть паттерна - динамическое создание и уничтожение Widget::Impl
:
// Widget.cpp
#include "Widget.h"
#include "Gadget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget(): pImpl(new Impl) {}
Widget::~Widget() { delete pImpl; }
Зависимости от std::string
, std::vector
и Gadget
остались, но они переехали из Widget.h
в Widget.cpp
.
В С++11 можем использовать std::unique_ptr
:
// Widget.h
class Widget {
public:
Widget();
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// Widget.cpp
#include "Widget.h"
#include "Gadget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget(): pImpl(std::make_unique<Impl>()) {}
Реализация make_unique
в C++11 может выглядеть так:
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
Understand std::move and std::forward
std::move
ничего не перемещает, а std::forward
ничего не форвардит. В рантайме они не делают ничего, потому что даже не генерируют исполняемого кода. Все что они делают - конвертируют типы.
std::move
приводит аргумент к rvalue.
std::forward
тоже приводит к rvalue, но только если аргумент был инициализирован как rvalue.
Пример реализации std::move
:
template<typename T>
typename remove_reference<T>::type&&
move(T&& param) {
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
Так как тип аргумента T&&
может означать как rvalue
, так и lvalue
, то нам нужен тип remove_reference
. Он снимает ссылку, если T
- ссылочный тип и оставляет все как есть, если нет.
Все rvalue
передаются перемещением, поэтому после применения std::move
на объекте, он сможет быть переданным куда-то путем перемещения, а не копирования.
Однако не всегда rvalue
передаются перемещением, а значит и std::move
не всегда будет перемещать. Например, значение не будет перещеаться из константы:
class Annotation {
public:
explicit Annotation(const std::string text)
: value(std::move(text))
{ ... }
private:
std::string value;
};
В примере выше text в value попадет путем копирования, так как он не может переместиться из константы.
Distinquish universal references from rvalue references
T&&
имеет два значения:
- rvalue reference
- universal reference
Универсальная ссылка может быть привязана к rvalue, lvalue, константам, не-константам, volatile, не-volatile, и даже к const volatile
-объектам.
Обычно универсальные ссылки используются в типах шаблонов и в auto-декларациях:
template<typename T>
void f(T&& param);
auto&& var2 = var1;
В обоих этих случаях присутствует вывод типа, поэтому используется универсальная ссылка.
Если вывода типа (к которому относится &&) нет, то T&&
означает ссылку на rvalue:
void f(Widget&& param);
Widget&& var1 = Widget();
Форма вывода типа очень важна, чтобы использовалась универсальная ссылка, тип обязательно должен быть T&&
. В следующем примере правило не соблюдается и поэтому используется ссылка на rvalue:
template<typename T>
void f(std::vector<T>&& param);
template<typename T>
void f2(const T&& param);
std::vector<int> v;
f(v); // ОШИБКА, lvalue не принимается
f2(v); // ОШИБКА, lvalue не принимается
Более того, не всегда присутствие T&&
в шаблоне означает использование универсальной ссылки. Вот пример:
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
...
};
Это не является универсальной ссылкой, так как T
полностью определяется конкретным классом с подставленными типамию Например, для класса Widget
:
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x);
...
};
Никакого вывода типов здесь нет.
В то же время, функция employ_back
использует вывод типа, а значит и универсальную ссылку:
template<class T, class Allocator = allocator<T>>
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);
...
};
Если поьзователь сам указывает типы при использовании шаблона, то вывода типов опять нет и универсальная ссылка не используется.
Use std::move on rvalue references, std::forward on universal references
Если у нас есть ссылка на rvalue, то этот объект точно может быть перемещен. Но внутри функции это уже становится ссылкой на lvalue. Чтобы не потратить возможность впустую, такие аргументы всегда должны передаваться через std::move
, чтобы они привелись к rvalue:
class Widget {
public:
Widget(Widget&& rhs) // ссылка на rvalue
: name(std::move(rhs.name)),
p(std::move(rhs.p))
{ ... }
...
}
В свою очередь, универсальная ссылка может быть привязана к объекту, который может быть перемещен. Их нужно перемещать (т.е. приводить к rvalue) только если они были инициализированы rvalue:
class Widget {
public:
template<typename T>
void setName(T&& newName) // универсальная ссылка
{ name = std::forward<T>(newName); }
...
}
Такой код не скопилится, если в него передавать newName
с типом, отличным от string. При этом он будет работать чуть производительнее кода, в котором аргументом принимается std::string&&
, так как в том варианте нужно создавать временный string для принятия аргумента, потом еще деструктить его, а здесь - этого всего не нужно, муваем напрямую из литерала.
Отсюда вывод: замена шаблона, принимающего универсальную ссылку парой функций, принимающих ссылки на rvalue и lvalue добавит оверхеда в рантайме. Помимо этого такая замена может привести к комбинаторному взрывы, когда аргументов станет больше одного.
Еще одно правило - не стоит никогда использовать std::move
на универсальных ссылках, потому что тогда может оказаться, что нам пришла ссылка на lvalue, а мы превратим ее объект в пустое значение.
return std::move не работает для локальных переменных
Все, сказанное выше, справедливо лишь для аргументов, полученных на вход функции. Если же у нас есть локальная переменная, которую мы хотим вернуть, то бессмысленно и даже вредно передавать ее через std::move
, потому что таким образом мы помешаем компилятору провести оптимизацию return value optimization (RVO).
Эта оптимизация конструирует возвращаемую локальную переменную сразу в той памяти, которая выделена для возвращаемого значения функции.
RVO используется, когда:
- Тип локальной переменной точно такой же, как и возвращаемый тип функции
- Возвращается локальная переменная
Пример:
Widget makeWidget() {
Widget w;
...
return w; // здесь будет произведено перемещение, а не копирование
}
Если же мы возвращаем std::move(w)
, то мы возвращаем ссылку на w
, а не саму w
. Это значит, что правило 1 о совпадении типов больше не соблюдается и RVO применена быть не может.
Но допустим, мы предполагаем, что в каком-то сложном коде компилятор не сможет применить RVO и решаем там заюзать std::move
. Как ни странно, это все еще плохая идея. Стандарт говорит, что если условия RVO выполнены, но компилятор решает не применять оптимизацию, то возвращаемый объект должен быть обработан как rvalue. То есть он сам за вас подставит std::move
.
То же правило справедливо и для аргументов, принимаемых по значению. То есть такой код:
Widget makeWidget() {
Widget w;
...
return w;
}
будет на самом деле скомпилирован как:
Widget makeWidget() {
Widget w;
...
return std::move(w);
}
При передаче аргументов-по-ссылке или локальных переменных в другие функции такие оптимизации не применяются, поэтому здесь можно спокойно использовать std::move
.
Резюме:
- применяйте
std::move
к ссылкам на rvalue иstd::forward
к универсальным ссылкам при последнем их использовании - делаейте то же самое для ссылок, возвращаемых из функций, которые возвращают по значению
- никогда не используйте
std::move
илиstd::forward
для локальных объектов, которые подходят для RVO
Avoid overloading on universal references
std:: multiset<std::string> names;
template<typename T>
void add(T&& name) {
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx);
void add(int idx) {
names.emplace(nameFromIdx(idx));
}
Здесь у нас 2 перегрузки метода add
. Пока мы в аргумент передаем string или int, все нормально.
Но если мы сделаем так:
short nameIdx;
add(nameIdx); // ошибка!
получим ошибку компиляции. Дело в том, что из двух перегрузок компилятор выбирает ту, которая принимает универсальную ссылку, так как там есть прямой матч типа на T, а в случае int-перегрузки матч непрямой, нужно еще приводить short к int. Далее, когда перегрузка с универсальной ссылкой пытается сдедлать names.emplace(std::foward<short>(name))
, получается ошибка, так как short не может быть добавлен в сет строк.
Функции, принимающие универсальные ссылки - самые жадные функции в C++, они подсовывают свои перегрузки как точные матчи для почти любых типов аргументов.
Особенно неприятно становится, когда у нас есть конструктор, принимающий универсальную ссылку. Компилятор автоматически генерирует кострукторы перемещения и копирования, а затем при попытке копирования объекта попытается использовать перегрузку с универсальной ссылкой, потому что она самая жадная, и выдаст ошибку компиляции:
class Person {
public:
template<typename T>
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx);
Person(const Person& rhs); // compiler-generated
Person(Person&& rhs); // compiler-generated
}
Person p("Nancy");
auto cloneOfP(p); // ОШИБКА
Вдвойне неприятно, потому что здесь мы даже не можем избавиться от перегрузок переименованием, ведь у конструкторов нельзя менять имена.
Здесь может помочь использование const
:
const Person p("Nancy");
auto cloneOfP(p); // компилится
Такой вариант сработал, потому что здесь сработал прямой матч типа const Person&
на сгенерированный конструктор копирования и такой матч сильнее, чем матч универсальной ссылки.
Familiarize yourself with alternatives to overloading on universal references
Тут очень много текста, поэтому просто приведы примеры кода для решения проблем, описанных в предыдущем пункте
Using tag dispatch
template<typename T>
void add(T&& name)
{
addImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}
template<typename T>
void addImpl(T&& name, std::false_type)
{
names.emplace(std::forward<T>(name));
void addImpl(int idx, std::true_type)
{
add(nameFromIdx(idx));
}
Constraining templates that take universal references
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
: name(std::forward<T>(n))
{... }
explicit Person(int idx)
: name(nameFromIdx(idx))
{... }
...
private:
std::string name;
};
Trade-off
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
: name(std::forward<T>(n))
{
// assert that a std::string can be created from a T object
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
...
}
...
};
Understand reference collapsing
При использовании универсальных ссылок в шаблное выводитмый тип T содержит в себе информацию, была ли передана ссылка на lvalue или rvalue. Например, если есть такое определение шаблона:
template<typename T>
void func(T&& param);
тип T будет выведен как:
- lvalue reference, если в качестве аргумента передана lvalue
- non-reference, если передана rvalue
Пример для нашего шаблона func
:
Widget w;
Widget widgetFactory();
func(w); // T => Widget&
func(widgetFactory()); // T => Widget
Благодаря этому правилу работают универсальные ссылки и std::forward
.
Пользователю нельзя делать ссылки на ссылки:
auto& & rx = x; // ОШИБКА
Но компилятор может, используя механизм reference collapsing. Благодаря этому механизму у нас есть универсальные ссылки, которые по сути есть схлопнутые rvalue reference на другую ссылку. Например, в следующем примере lvalue передается в шаблон функции, принимающий rvalue:
template<typename T>
void func(T&& param);
auto w = makeWidget();
func(w);
Для T выводится тип Widget&
, а значит конкретная функция имеет вид:
void func(Widget& && param);
Видим тут ссылку на ссылку и компилятор ничего не имеет против.
Дело в том, что если ссылка на ссылку появляется в разрешенном контексте (напр. при конкретизации шаблона), то ссылки "схлопываются" в одну ссылку по следующему правилу:
Если любая из ссылок ссылается на lvalue, то результат становится ссылкой на lvalue. Иначе, если обе ссылки на rvalue, то результат становится ссылкой на rvalue.
- & + & = &
- & + && = &
- && + & = &
- && + && = &&
В примере выше у нас получается rvalue reference на lvalue reference, что схлопывается в lvalue reference. Если бы на вход поступила rvalue reference, то результат схлопывания был бы тоже rvalue reference. Именно так и работает универсальная ссылка.
Схлопывание ссылок разрешено в 4 контекстах:
- конкретизация шаблона
- генерация типов для auto (
auto&& w1 = w;
выводит lvalue) - генерация и использование
typedef
и алиасов (typedef T&& Ref
) - использование
decltype
Assume that move operations are not present, not cheap, and not used
Многие типы стандартной библиотеки были переипсаны в C++11, чтобы поддержать семантику перемещения. Однако многие остались пока без этой поддержки.
Среди тех, которые поддерживают перемещение, некоторые типы перемещают медленно просто потому что семантика типа не позволяет сделать это иначе. Например, std::array
хранит все элементы в себе, поэтому для него перемещение, хоть и работает быстрее копирования, все равно требует линейного перемещения всех своих объектов. Для остальных контейнеров, как, например, std::vector
, перемещение заключается в перемещении указателя на начало массива в куче, поэтому выполняется за константное время.
std::string
, в свою очередь, поддерживает перемещение за константное время, но оно не всегда становится от этого сильно быстрее линейного копирования. Дело в том, что многие реализации используют small string optimization (SSO). Благодаря этой оптимизации "маленькие" (не более 15 символов) строки хранятся в буфере внутри объекта std::string
а динамически аллоцируемая память не используется. Перемещение таких "маленьких" строк работает не быстрее, чем копирование.
А еще бывает так, что даже при корректной и производительной реализации перемещения, компилятор все равно выбирает копирование, потому что реализация перемещения не помечена как noexcept
. Так бывает, например, в контейнерах, когда компилятор хочет убедиться, что не возникнет ситуации, когда при перемещении i-го элемента выбросится исключение и у нас получится 2 объекта в неконсистентном состоянии.
Avoid default capture modes
Захват по ссылке может привести к висячим ссылкам, если время жизни замыкания превышает время жизни переменной, ссылка на которую используется в лямбде. Когда мы такие ссылки провисываем в списке захвата вручную, то за их лайфтаймом легче уследить.
Казалось бы, эту проблему может решить захват по значению. Но если мы захватываем по значению указатель, никто не мешает коду вне лямбды освободить память, на которую ссылается этот указатель, и мы опять получаем висячую ссылку.
Вообще, надо понимать, что при использовании дефолтного захвата по значению, захватываются только локальные переменные, аргументы функции и указатель this
.
Статические объекты могут быть использованы внутри лямбды, но они не захватываются. То есть захват по значению для них не работает в том смысле, что не делается копия значения, как пользователь может ожидать при использовании [=]
.
Поля объекта тоже захватываются, но при использовании [=]
может сложиться ложное впечатление, что они захватились по значению, в то время как на самом деле захватился this
и обращение к полям объекта идет автоматически через него, то есть обращение к полям объекта опять-таки идет по ссылке.
Use init capture to move objects into closures
У лямбд в C++11 есть большая проблема - туда нельзя передать move-only объекты, то есть, например, std::unique_ptr
или std::future
.
Проблему эту позволяет решить init capture, появившаяся в C++14:
class Widget {
public:
...
bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private: ...
};
auto pw = std::make_unique<Widget>();
...
auto func = [pw = std::move(pw)] {
return pw->isValidated()
&& pw->isArchived();
};
В С++11 без использования этой фичи код может быть переписан так:
class IsValAndArch {
public:
using DataType = std::unique_ptr<Widget>;
explicit IsValAndArch(DataType&& ptr)
: pw(std::move(ptr)) {}
bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }
private:
DataType pw;
};
auto func = IsValAndArch(std::make_unique<Widget>());
Есть и другой, чуть более сложный для понимания, но требующий меньше кода, вариант:
auto func =
std::bind(
[](const std::unique_ptr<Widget>& pws)
{
return pw->isValidated() && pw->isArchived();
},
std::make_unique<Widget>()
);
Use decltype on auto&& parameters to std::forward them
В C++14 появились джененрик лямбды, у которых в списке параметров можно использовать тип auto
:
auto f = [](auto x){ return func(normalize(x)); };
Сгенерированный класс замыкания выглядит так:
class SomeCompilerGeneratedClassName {
public:
template<typename T>
auto operator()(T x) const
{ return func(normalize(x)); }
...
};
Но ва этой лямбде есть недостаток - она плохо работает со ссылками на rvalue, а именно не форвардит их.
Исправленный вариант:
auto f = [](auto&& x)
{ return func(normalize(std::forward<decltype(x)>(x))); };
Если нужна лямбда, принимающая множество параметров, можем применить вариадичный шаблон:
auto f = [](auto&&... params)
{
return func(normalize(std::forward<decltype(params)>(params)...));
};
Prefer lambdas to std::bind
- Лямбды намного более читабельны.
- Лямбды могут инлайниться, бинды - нет.
- У биндов неочевидная семантика копирования аргументов - при создании бинда аргументы копируются по значению, а при использовании результата аргументы передаются по функции. И это из кода нигде не понятно, это можно только запомнить.
В С++11 без использования бинда не обойтись, когда:
- хотим передать аргументы в замыкание перемещением
- дженерик лямбда
Дженерик лямбда через бинд реализуется так:
class PolyWidget {
public:
template<typename T>
void operator()(const T& param);
...
};
PolyWidget pw;
auto boundPW = std::bind(pw, _1);
boundPW(1930);
boundPW(nullptr);
boundPW("Rosebud");
В С++14 лямбды поддерживают оба этих пункта, поэтому там смысла использовать бинд нет вообще никогда.
Prefer task-based programming to thread-based
Thread-based:
std::thread t(doAsyncWork);
Task-based:
auto fut = std::async(doAsyncWork);
Основные различия:
- таска возвращает значение, в котором клиент может быть заинтересован. Из потока получить значение не так просто
- если функция стреляет исключение, то в случае таски оно будет выстрелено при попытке взять значение, а в случае потока оно крэшнет весь процесс
- если превышен максимально возможный лимит потоков в системе, конструктор
std::thread
выброситstd::system_error
. Таски же могут быть запущены в текущем потоке, если потоков в системе слишком много. Для GUI-потоков можно передавать специальную политику запускаstd::launch::async
, чтобы гарантировать, что таска точно будет запущена в отдельном потоке - таски имеют больше информации о загруженности системы, чтобы проводить более эффективную балансировку нагрузки
Использование тредов оправдано, когда:
- нужен доступ к низкоуровневым свойствам тредов, например, приоритету
- нужно вручную затюнить использование тредов в приложении
- нужно реализовать механизмы мульти-трединга, отсутствующие в C++ concurrency API, например, тред-пулы
Specify std::launch::async if asynchronicity is essential
Таски обычно создаются так:
auto fut = std::async(f);
auto fut2 = std::async(std::launch::async, f);
Во втором варианте вторым аргументом передается политика запуска.
Есть 2 стандартных политики запуска:
std::launch::async
- функция должна быть обязательно запущена асинхронно, т.е. на другом потокеstd::launch::deferred
- функция будет запущено только когда для футуры будет вызванget
илиwait
. Если не будет вызван, то функция никогда не запустится.
Дефолтная политки запуска при этом - объединение этих двух: std::launch::async | std::launch::deferred
. Она значит, что функция может быть запущена как синхронно, так и асинхронно, в зависимости от текущей нагрузки.
У дефолтной политики есть и свои недостатки:
- невозможно предсказать, будет ли функция выполняться одновременно с текущим потоком, создающим таск
- невозможно предсказать, будет ли функция выполняться на другом потоке при вызове
get
илиwait
- невозможно предсказать, будет ли функция выполнена вообще, потому что может быть непросто гарантировать, что
get/wait
будут точно вызываться во всех ветях исполнения - плохо работает с thread_local переменными, потому что невозможно предсказать, переменные чьего потока будут использоваться
- использование функций
wait_for
иwait_until
может сломаться, потому что если использовать их на таске, вызванном с политикойstd::launch::deferred
, то они будут всегда возвращатьstd::launch::deferred
и никогда -std::future_status::ready
Все эти баги сложно отследить, потому что они будут проявляться только под большой нагрузкой. Вдобавок к этому, нет никакой возможности узнать, отложен ли таск. Приходится пользоваться таким вот костылем:
auto fut = std::async(f);
if(fut.wait_for(0s) == std::future_status::deferred) {
auto res = fut.get();
...
} else {
while(fut.wait_for(100ms) != std::future_status::ready) {
...
}
auto res = fut.get();
...
}
Make std::threads unjoinable on all paths
К unjoinable-тредам относятся:
std::thread
с дефолтным конструктором, то есть без указанной функцииstd::thread
, из которого "перестили"- приджойненные треды
- задетаченные треды
Be aware of varying thread handle destructor behavior
И std::thread
и футуры могут считаться хэндлами системных потоков. Но они очень сильно различаются по поведению деструкторов.
Деструктор потока вызывает terminate()
если тред не заджойнен.
Деструктор футуры иногда как будто бы делает join
, иногда detach
, а иногда ни то, ни другое. Но он точно никогда не вызывает завершение программы.
Напомним, что футура - это один конец канала общения клиента потока и функтора, работающего в другом потоке. Фуктор, используя объект std::promise
, пишет результат вычисления в канал, а клиент читает этот результат с помощью футуры.
Однако на самом деле все чуть сложнее. Что если функтор вызывается раньше, чем клиент вызовет get()
у футуры? Результат нужно где-то хранить. В промисе - нельзя, потому что функтор завершается и вместе с ним может завершиться промис. В футуре - тоже нельзя, так как из std::future
может быть сделано std::shared_future
и тогда результат придется копировать несколько раз. А если результат не-копируем, то придется как-то заранее знать, какая из std::shared_future
умрет последней и хранить в ней.
Поэтому результат операции хранится снаруже футуры и промиса, в месте под названием shared state:
Вот это вот разделяемое состояние определяет поведение деструктора футуры:
- деструктор последней футуры, ссылающеся на разделяемое состояние для не-отложенной (
std::launch::async
) таски, запущенной черезstd::async
блочится, пока таска, связанная с футурой не завершится. Дляstd::future
это условие выполняется всегда, дляstd::shared_future
- только для последней. - деструкторы остальных футур просто уничтожают футуры и ничего не ждут
Для обычных std::future
можно говорить, что если они запущены с std::launch::async
, то их деструкторы будут блочиться.
Имея объект футуры, невозможно определить, заблочится ли она в деструкторе.
Consider void futures for one-shot event communication
Когда одному потоку надо дождаться некоего события во втором потоке, есть такие опции:
std::condition_variable
Thread 1:
std::condition_varaible cv;
std::mutex m;
...
cv.notify_one();
Thread 2:
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk);
...
}
Недостатки:
- если тред 1 занотифаит прежде чем тред 2 начал ждать, то тред 2 зависнет.
wait
в треде 2 подвержен ложным пробуждениям (spurious wakeups). Решается передачей предиката вwait
. Но не всегда есть возможность в предикате сообщить треду 2, что произошло нужное событие в треде 1.- Мьютекс используется не по назначению (его назначение - синхронизировать доступ к переменным, а не передавать события)
- разделяемый
std::atomic<bool>
Thread 1:
std::atomic<bool> flag(false);
...
flag = true;
Thread 2:
while(!flag);
...
Недостатки:
- жрет процессорное время
- жрет ресурсы для контекст-свитча
- не-атомарный флаг, но под мютексом
Thread 1:
std::condition_variable cv;
std::mutex m;
bool flag(false);
...
{
std::lock_guard<std::mutex> g(m);
flag = true;
}
cv.notify_one();
Thread 2:
...
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{return flag; });
...
}
...
Недостатки:
- сложный код, фактически передаем информацию о наступлении события дважды - через
cv
и черезflag
- все еще нужно проверять flag в
wait
- футура, возвращающая void!
Thread 1:
std::promise<void> p;
...
p.set_value();
Thread 2:
p.get_future().wait();
Не требует мутексов, работает если set_value()
вызван раньше, чем wait()
и не подвержен ложным пробуждениям.
Недостатки:
- динамически аллоцируемое shared state между промисом и футурой
- промис может быть выставлен только единожды, то есть этот механизм годится только для одноразовых сообщений. Это главное отличие от механизмов, построенных на использовании condvar и флагов.
Типичный пример использования техники:
std::promise<void> p;
void react();
void detect() {
std::thread t([]
{
p.get_future().wait();
react();
});
...
p.set_value();
...
t.join();
}
Если использовать здесь std::shared_future
, то подписчиков на событие может быть несколько:
std::promise<void> p;
void react();
void detect() {
auto sf.p.get_future().share();
std::vector<std::thread> vt;
for(int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{
sf.wait();
react();
});
}
...
p.set_value(); // запускаем все ожидающие потоки
...
for(auto& t: vt) {
t.join();
}
}
Use std::atomic for concurrency, volatile for special memory
Операции над std::atomic
производятся, как будто бы они были защищены мютексом, но при этом используют специальные машинные инструкции, которые эффективнее мютексов.
А еще std::atomic
нагладывает ограничения на изменение порядка инструкций, а именно: ни одна команда, предшествующая записи вstd::atomic не может быть расположена после нее.
volatile
не делает ничего из этого, поэтому оно не подходит для параллельного программирования.
Так для чего же оно нужно? А нужно оно, чтобы сказать компиляторам, что они работают с памятью, которая ведет себя "ненормально".
Под "нормальной" памятью мы понимаем такую память, для которой если мы записали значение по некоторому адресу, то он останется неизменным, пока что-нибудь его не перепишет.
То есть если есть такой код:
int x;
...
auto y = x;
y = x;
x = 10;
x = 20;
компилятор может сделать вывод, что между двумя присвоениями значение x
не меняется, а значит первое присвоение в каждой паре можно безопасно удалить:
int x;
...
auto y = x;
x = 20;
Но применительно к ключевому слову volatile
мы говорим о "ненормальной", или "специальной" памяти. Наиболее часто используемая "специальная" память используется при memory mapped I/O
. В такой памяти адреса общаются напрямую с периферийными устройствами, то есть сенсорами, дисплейями, принтерами, сетевыми портами и т.д.
В этой ситуации если видим такой код:
auto y = x;
y = x;
нельзя удалить второе присвоение, потому что, например, сенсор, к которому привязано хначение x мог изменить показания
Или вот такой код:
x = 10;
x = 20;
Его возможно тоже нельзя менять, если, например, запись в x издает определенную радиокоманду.
Ключевое слово volatile
говорит компилятору, что здесь мы работаем с именно такой "специальной" памятью. Это приказ компилятору "не проводи никаких оптимизаций на этой памяти".
При его использовании:
volatile int x;
auto y = x;
y = x;
x = 10;
x = 20;
никакие строчки кода компилятором удалены не будут.
Теперь должно быть понятно, почему std::atomic
нельзя использовать для работы со "специальной" памятью, ведь он не запрещает удалять лишние, с точки зрения компилятора, строчки.
Ну и не лишним будет добавить, что приведенный выше код для std::atomic
вообще не скомпилится, потому что операции копирования для std::atomic
удалены. Это сделано потому что атомарное копирование обычно не поддерживается железом. Перемещение тоже не поддерживается.
Если нужно скопировать значение x
в y
, то нужно делать так:
std::atomic<int> y(x.load());
// или так
y.store(x.load());
Но между чтением и записью, естественно, никакой атомарности не сохраняется.
Компилятор мог бы оптимизировать этот код:
register = x.load();
std::atomic<int> y(register);
y.store(register);
и если бы мы работали со специальной память, такая оптимизация была бы неприемлема. Это еще один пример, почему std::atomic
и volatile
не взаимозаменяемы:
std::atomic
- для данных, доступ к которым осуществляется из нескольких тредов без использования мютексов. Он нужен для написания параллельного кода.volatile
- для памяти, у которой чтения и записи не должны быть оптимизированы. Это нужно для работы со специальной памятью.
Consider pass by value for copyable parameters that are cheap to move and always copied
Пусть у нас есть такой вот код:
class Widget {
public:
void addName(const std::string& newName) {
names.push_back(newName);
}
void addName(std::string&& newName) {
names.push_back(std:move(newName));
}
private:
std::vector<std::string> names;
}
С точки зрения эффективности тут все хорошо: lvalue передаются по ссылки и копируются, rvalue тоже передаются по ссылке, но перемещаются. Но есть проблема - это 2 функции вместо одной.
Можем попробовать переписать с использованием универсальной ссылки:
class Widget {
public:
template<typename T>
void addName(T&& newName) {
names.push_back(std::forward<T>(newName));
}
...
};
Здесь меньше кода, но так как это шаблон, то в объектном коде использующей библиотеки все равно будет 2 функции. На самом деле даже больше - по 2 на std::string
и каждый тип, приводимый к std::string
.
Кроме того, есть типы, которые не могут быть переданы как универсальная ссылка.
Выход простой - передаем копию и не паримся:
class Widget {
public:
void addName(std::string newName) {
names.push_back(std::move(newName));
}
}
Почему это работает? Дело в том, что компилятор C++11 стал достаточно умен, чтобы в случае, когда аргументом передается rvalue, передавать его не копированием, а перемещением. Тогда в случае передачи lvalue у нас получается копирование и перемещение, а в случае rvalue - два перемещения. По сравнению с перегрузками и универсальными ссылками - это на одно перемещение больше в обоих случаях.
То есть такой вариант все-таки чуть менее эффективен. Именно поэтому в заголовке пункта стоит уточнение, что тип должен быть дешев для перемещения.
Там еще есть уточнение про копируемые типы. Дело в том ,что если тип не копируем, то в варианте с перегрузками не нужна первая перегрузка. Тогда достаточно одной функции и первый вариант не имеет недостатков.
И последнее уточнение - применять эту технику стоит лишь для аргументов, которые в теле функции всегда копируются. Иначе перемещение копирования в заголовок может быть лишним оверхедом для тех случаев, когда значение потом не используется и получается, что копировали зря. В таком случае передача ссылки подошла бы лучше.
Consider emplacement instead of insertion
У std::vector
есть такая перегрузка специально для rvalue:
void push_back(T&& x);
Рассмотрим такой код, использующий ее:
std::vector<std::string> vs;
vs.push_back("xyzzy");
Как ни странно, этот невинный код приводит аж к 2 конструкциям типа string
:
- первый раз конструктор вызывается, чтобы привести литерал типа
const char[6]
к типуstd::string
, получим временное значениеtemp
- второй раз вызывается внутри метода
push_back
, чтобы скопировать значение из ссылки rvalue наtemp
.
Не очень-то эффективно 2 раза вызывать конструктор. Хотелось бы обойтись лишь одним. Поэтому был введен специальный метод - emplace_back()
.
Он форвардит свои аргументы в std::vector
, чтобы внутри него.с этими аргументами вызвать конструктор std::string
, достигая того же эффекта, что и push_back()
без использования временных переменных.
Пример:
vs.emplace_back("xyzzy");
vs.emplace_back(50, 'x'); // создаст std::string из 50 символов x
Метод emplace_back
доступен для всех стандартных контейнеров, у которых есть push_back
.
Если передать в emplace_back
не char[]
, а сразу std::string
, то он будет вести себя аналогично push_back
, так как в этом случае push_back
создает лишь один объект. То есть по идее он может во всех ситуациях заменить push_back
. Но это только по идее.
В действительности, бывают ситуации, когда push_back
работает быстрее. Эти ситуации зависят от многих факторов - от типа аргумента, типа контейнера, позиции в контейнере, куда вставляется элемент, возможности выстреливания исключения из операции копирования, а для контейнеров, в которых запрещены дубликаты - от того, есть ли уже такое значение в контейнере. ТАк что единственный способ узнать наверняка - побенчмаркать.
Но есть эвристические проверки, которые помогут определить, когда emplace_back
сработает быстрее, чем push_back
. Если все эти проверки проходят, то это так:
- добавляемое значение конструктится, а не присваивается. Это зависит от реализации метода добавления, но обычно конструктится, когда, мы добавляем новое значение в контейнер, а присваивается - когда заменяем старое. Если присваивается, то придется создавать временный объект, из которого будет осуществлено перемещение, а значит
emplace_back
теряет свои достоинства - тип передаваемого аргумента отличается от типа элементов контейнера
- контейнер не будет, или очень редко будет отвергать новое значение как дубликат. Это важно, потому что когда значение отвергнуто, мы в
emplace_back
уже создали объект и получается, что создали зря