Объект
Объект - это участок памяти, у которого есть
- Размер
- Выравнивание
- Тип размещения
- Время жизни
- Тип
- Значение (может быть не определено)
- Имя (необязательно)
Объектами не являются
- Значения
- Ссылки
- Функции
- Перечисление (
enum
) - Типы
- Нестатические типы класса
- Шаблоны
- Специализация класса или функции
- Namespace
- Parameter pack
this
Гарантируется, что два объекта, время жизни которых пересекаются имеют разный адрес в памяти (если один их них не является подобъектом другого).
Размер любого полного (complete
) объекта должен быть больше нуля.
struct empty {};
static_assert(sizeof(empty) > 0);
static_assert(sizeof(empty) == 1);
Выравнивание
Выравнивание - это ограничение на то, с какой ячейки памяти по счету может храниться первый бит объекта. Выравнивание необходимо для ускорения доступа к памяти, к примеру, на архитектуре x86-64. Невыровненные данные обрабатываться могут, но это может быть медленно на некоторых архитектурах.
Также его важно учитывать при использовании векторных инструкций процессора, которые значительно ускоряют вычисления в некоторых случаях. Невыровненные данные в этом случае могут привести к генерации аппаратного исключения. Для справки, для SSE выравнивание должно составлять 16 байт, для AVX - 32 байта.
Выравнивание в 16 единиц означает, что валидным адресом для доступа будет только значение, делящееся на 16.
Выравнивание существует у каждого объекта. Для задания своего выравнивания в байтах у соответствующей переменной существует ключевое слово alignas
, которое принимает степени двойки: 2, 4, 8, 16, 32, 64, 128 и так далее.
alignas(16) int a[4];
alignas(1024) int b[4];
Получить выравнивание объекта в compile-time можно с помощью ключевого слова alignof
, причем возвращается наибольшая из степеней двойки.
static_assert(alignof(b) == 16); // fail
static_assert(alignof(b) == 1024); // OK
Типы размещения
Спецификаторы
(no specifier)
- автоматическоеstatic
- статическое (внутреннее связывание)extern
- так же статическое (внешнее связывание)thread_local
- тред-локальное размещение
Автоматическое размещение
Память под объект аллоцируется на стеке в начале открыващегося блока кода и деаллоцируется в конце.
Статическое размещение
Память под объект аллоцируется перед исполнением программы и деаллоцируется в конце.
Если переменная со статическим размещением не инициализируется разработчиком, то она будет проинициализирована нулем соответствущего типа.
Тред-локальное размещение
Память под объект аллоцируется при создании потока и деаллоцируется при его завершении. Каждый поток обладает собственным экземпляром объекта.
Динамическое размещение
Память под объект аллоцируется и деаллоцируется по запросу программы при помощи специальных функций. При закрытии программы все недеаллоцированные ранее области памяти деаллоцируются средствами ОС.
(не уверен в последнем пункте, так как не силен за shared memory).
Квалифицированный поиск
#include <iostream>
int main() {
struct std {};
std::cout << "fail"; // Error: unqualified lookup
::std::cout << "ok"; // OK, qualified
}
struct A {
static int n;
};
int main() {
int A;
A::n = 42; // OK: ignoring the variable
A b; // Error: unqualified lookup of A finds the variable A
}
Квалифицированный поиск может быть использован для доступа к членам класса, которые были скрыты вложенным объявлением или содержатся в наследуемом классе. Вызов квалифицированного метода класса не учитывает виртуальное объявление.
struct B {virtual void foo();};
struct D : B {void foo() override;};
int main() {
D x;
B& b = x;
b.foo(); // calls D::foo (virtual dispatch)
b.B::foo(); // calls B::foo (static dispatch)
}
Псевдонимы пространств имен
Синтаксис:
namespace alias_name = ns_name namespace alias_name = ::ns_name namespace alias_name = nested_name::ns_name
Псевдоним alias_name будет виден в том блоке, в котором он объявлен.
Using-директива
Синтаксис:
using namespace ns_name
С точки зрения неквалифицированного поиска имен любой символ, объявленный в ns_name, после using-директивы и до конца блока ее объявления будет виден, как если бы он был объявлен в ближайшем пространстве имен, которое одновременно содержит как using-директиву, так и ns-name.
Using-объявление
Синтаксис:
using ns_name::member_name
Делает символ member-name из пространства имен ns-name доступным для неквалифицированного поиска, как если бы он был объявлен в том же классе, блоке или пространстве имен, в котором using-объявление появляется.
Анонимные пространства имен
Синтаксис:
namespace { namespace-body
}
Пример кода:
namespace {
int i; // defines ::(unique)::i
}
void f() {
i++; // increments ::(unique)::i
}
namespace A {
namespace {
int i; // A::(unique)::i
int j; // A::(unique)::j
}
void g() { i++; } // A::(unique)::i++
}
using namespace A; // introduces all names from A into global namespace
void h() {
i++; // error: ::(unique)::i and ::A::(unique)::i are both in scope
A::i++; // ok, increments ::A::(unique)::i
j++; // ok, increments ::A::(unique)::j
}
Символы внутри анонимных пространств имен (так же как и любые символы, объявленные внутри именованных пространств внутри других анонимных) имеют внутреннее связывание, как если бы им приписали static
.
ADL (Argument-dependent lookup)
Позволяет не указывать явно пространство имен функций при вызове, если какие-либо их аргументы объявлены в том же пространстве имен.
namespace MyNamespace {
class MyClass {};
void doSomething(MyClass) {}
}
MyNamespace::MyClass obj; // global object
int main() {
doSomething(obj); // works fine - MyNamespace::doSomething() is called.
}
Такой поиск запускается только для имён, у которых имя неквалифицированное.
Важно знать, что у поиска ADL и обычного нет приоритетов, они запускаются оба и кандидаты из обоих уходят в overload resolution. Обычно они либо оба найдут одно и то же, либо обычный не найдет ничего и возьмется из ADL.
Но есть такой tricky пример кода:
using std::swap;
swap(obj1, obj2);
Если существует пространство имен A
и если существуют A::obj1
, A::obj2
и A::swap()
, то произойдет вызов A::swap()
вместо вызова std::swap()
, чего разработчик иногда может не ожидать. todo: с чем связано?
Заимствования:
Argument-dependent lookup - cppreference.com
Правила ODR:
- В пределах любой единицы трансляции шаблон, тип данных, функция или объект не могут иметь более одного определения, но могут иметь неограниченное число объявлений.
- В пределах программы (совокупности всех единиц трансляции) объект или не-inline функция не могут иметь более одного определения; если объект или функция используются, у каждого из них должно быть строго по единственному определению.
- Типы, шаблоны и inline-функции (то есть те сущности, определение которых полностью или частично совмещается с их объявлением) могут определяться в более чем одной единице трансляции, но для каждой такой сущности все её определения должны быть идентичны.
ODR можно легально нарушить с помощью ключевого слова inline
.
Связывание (linkage)
Без связывания
Символ доступен только из блока, в котором был объявлен.
Внутреннее связывание (internal linkage)
Символ доступен из всех блоков в данной единице трансляции.
Внешнее связывание (external linkage)
Символ доступен из всех блоков в других единицах трансляции.
Модульное связывание (module linkage)
Символ доступен из всех блоков только данного модуля или в других единицах трансляции того же самого именованного модуля.
Символы, определенные в пространстве имен, имеют модульное связывание, если их объявление находится в именованном модуле и не было экспортировано (exported
), и если они не имеют внутреннего связывания.
// TODO: не уверен в этом пункте. нужно изучить этот вопрос.
Какие ключевые слова влияют на связывание?
- Использование
static
в глобальном пространстве имен дает символу внутреннее связывание. - Использование
extern
дает внешнее связывание.
Компилятор по умолчанию дает символам следующие связывания:
- Non-const глобальные переменные - внешнее связывание
- Const глобальные переменные - внутреннее связывание
- Функции - внешнее связывание
- Объявление в анонимном пространстве имен - внутреннее связывание.
То есть, например, константы в файлах .h
, по умолчанию будут продублированы в каждой единице трансляции при подключении через #include
. То есть каждый файл .cpp
будет иметь свою собственную константу.
Это может негативно сказаться на скорости компиляции при изменении этой константы, так как перекомпилироваться будут все единицы трансляции, в которых эта константа присутствовала.
Чтобы этого избежать, можно пометить константы в файле .h
как extern
, затем инициализировать их единожды в каком-нибудь .cpp
, затем подключать этот хедер где угодно.
// constants.h
#ifndef CONSTANTS_H
#define CONSTANTS_H
namespace constants {
extern const double pi;
extern const double avogadro;
}
#endif
// constants.cpp
#include "constants.h"
namespace constants {
// keyword extern can be omitted
extern const double pi { 3.14159 };
extern const double avogadro { 6.0221413e23 };
}
Однако у этого метода есть недостаток. Эти константы теперь могут считаться константами времени компиляции только в файле, в котором они фактически определены (constants.cpp
), а не где-либо еще.
Ключевое слово inline
Ключевое слово inline
говорит линкеру игнорировать то, что в нескольких .cpp
файлах встречаются одинаковые определения. Линкер будет брать рандомное, предполагая, что все они равны. Если они не равны, поведение не определено.
Также слово inline
может объявлять inline-переменную и влиять на связывание (linkage) этой переменной начиная со стандарта C++17, если она глобальная, дополняя правила связывания переменных:
static
- внутреннее связываниеextern
- внешнее связываниеinline
- внешнее связываниеconst
&&inline
- внешнее связываниеconst
&& !inline
- внутреннее связывание
Предыдущий пример можно переписать следующим образом:
// constants.h
#ifndef CONSTANTS_H
#define CONSTANTS_H
namespace constants {
inline constexpr double pi { 3.14159 };
inline constexpr double avogadro { 6.0221413e23 };
}
#endif
Также можно неформально сказать, что все шаблонные классы/методы/функции являются inline автоматически.
Constexpr-функции также неявно являются inline.
Заимствования:
Inline variables / Хабр (habr.com)
Ссылки и указатели
Ссылка - это псевдоним к определенной ранее переменной. Указатель - это объект, значением которого является адрес ячейки памяти.
Разница?
Инициализация
Ссылку обязательно инициализировать. Она не может указывать вникуда или быть пустой, в отличие от указателя, которому задавать начальное значение необязательно.
Изменение значения
Ссылка не может быть переопределена, вместо этого присвоится значение переменной, на которую ссылка ссылается, если она не константная. Присваивать же новое значение указателю можно, если он не константный.
Взятие адреса
Ссылка - псевдоним к переменной, а не объект. Взятие адреса от ссылки будет возвращать адрес объекта, на который ссылка ссылается. Взятие же адреса указателя будет возвращать адрес указателя, а не объекта, на который указатель ссылается.
По стандарту ссылка - не объект, но при определенных условиях, ссылки могут компилироваться в указатели.
Рекомендация: используйте ссылки вместо указателей, если вам не нужно передавать пустое значение (читай, нулевой указатель).
Интересное про ссылки
Краткие факты
- Ссылки на ссылку не бывает. Они коллапсируют в одну.
- Ссылки типа
void
не бывает. - Ссылка может продлить время жизни объекта, если это
lvalue
-ссылка на const илиrvalue
-ссылка.
Dangling reference
Ссылки были созданы, чтобы сделать указатели безопаснее, но даже их можно сломать.
#include <iostream>
int& bar() {
int n = 10;
return n;
}
int main() {
int& i = bar();
std::cout << i << std::endl;
}
Функция bar()
вернет ссылку на локальную переменную, которая уничтожится при выходе из нее. Соответственно, такая ссылка будет невалидной и операции с ней - это undefined behavior.
Но вот так делать можно, конечно.
#include <iostream>
const int& bar(const int& a) {
return a;
}
int main() {
const int& i = bar(42);
std::cout << i << std::endl;
}
Передача массива по ссылке
Не секрет, что для передачи массивов в функции можно использовать указатели, но мало кто догадается, что можно использовать и ссылки. Но сначала: каким образом обычно передают указатели на массивы?
- Либо это указатель на массив фиксированного размера
- Либо это указатель на массив с передачей размера отдельным аргументом
В примере ниже мы сделаем первый вариант, но схитрим, написав шаблон.
#include <type_traits>
// Конвертирует строковый литерал в число
template <size_t N>
constexpr int convert(const char (&in)[N]) {
int res = 0;
for (const char * c = in; *c; ++c) {
(res *= 10) += *c - '0';
}
return res;
}
// Вычисляет длину массива
template <typename T, size_t N>
size_t len(T (&a)[N]) {
return N;
}
static_assert(convert("123") == 123);
Замечание: constexpr
здесь нужен только для демонстрации static_assert
.
Остальные фишки ссылок будут в другой заметке.
Интересное про указатели
Краткие факты
- Можно сделать указатель на указатель на указатель...
- Поддерживают арифметику (только указатели в рамках одного массива. остальное - undefined behavior)
Указатели на массивы фиксированной длины
int (*a)[2]; // create pointer to int[2]
int b[2];
int c[2];
a = new int[2]; // compile error (returns int*)
a = &b; // OK (returns int(*)[2])
a = (int(*)[2]) (new int[2]); // OK
a = &c; // compile error (returns int(*)[3])
Многомерные массивы на стеке
При объявлении многомерных массивов на стеке память выделяется непрерывно, что позволяет компилятору при индексации делать прыжок по указателю единожды.
int x[10][10]; // выделенный на стеке многомерный массив
int a = x[2][3]; // один прыжок
Но при индексации от указателя на него программа будет совершать честные прыжки в количестве измерений.
int **y = x;
int b = y[2][3]; // два прыжка
Как читать мешанину со словом const
Ключевое слово const
относится к тому, что слева, если не в начале строки, иначе к тому, что справа.
int a = 42;
const int b; // нет инициализации, compile error
const int c = a; // константный int
int const d = a; // то же самое
const int * e = &a; // изменяемый указатель на неизменяемый int
int const * f = &a; // то же самое
int * const g = &a; // неизменяемый указатель на изменяемый int
const int * const h = &a; // константный указатель на константу
Указатель на функцию
Функции приводятся к указателям и наоборот налету, неявно. Синтаксис для их объявления приведен ниже:
double sum(int a, long b) {
return a + b;
}
double (*ptr)(int, long) = sum;
double (*ptr)(int, long) = ∑ // эквивалентно
double c = (*ptr)(1, 2l);
double c = ptr(1, 2l); // эквивалентно
Массив указателей на массив
Интересно объявлен тип указателя C, разгадка будет ниже.
int A[5][5];
int B[5][5];
int (*C[])[5][5] = {&A, &B};
Пример от Артема К
double (* (*x[10]) (int &))[5];
x — это массив из 10 указателей на функции, которые принимают аргументом int&
, а возвращают массив из 5 double
.
Как парсить подобные вещи
- Парсим изнутри, начиная с имени переменной
- Идем вправо, потом влево
- Потом на следующий уровень наружу
Пример:
void* (*y[5])(char);
y — это:
- массив из пяти
- указателей
- на функцию, принимающую char
- и возвращающую void*
Пример от Артема К
int (* (** (* (* x)[5])(void))[10])();
x - это указатель на массив размера 5 из указателей на функции, принимающие void (то есть не принимающие аргументов - это альтернативный синтаксис) и отдающие указатель на указатель на массив размера 10 из указателей на функции без аргументов, возвращающие int
До прихода C++11
lvalue
- это то, что может стоять слева от оператора присваивания.rvalue
- это "временные объекты", им нельзя что-то присваивать. Кажется, что у них нельзя было взять адрес.
С приходом C++11 появилась move-семантика, из-за чего схема стала древовидной и усложнилась в понимании.
Сейчас
Введем пару понятий:
- Наличие идентичности (identity) – наличие какого-то параметра, по которому можно понять, ссылаются ли два выражения на одну и ту же сущность (например, адрес в памяти)
- Возможность перемещения (можно ли объект переместить, помувать)
Выражения сейчас делятся на два больших типа:
glvalue
- обладают идентичностьюrvalue
- могут быть перемещены
Эти категории распадаются в сумме на три (одна у них общая).
-
glvalue
lvalue
- обладают идентичностью, но не могут быть перемещеныxvalue
- обладают идентичностью, могут быть перемещены
-
rvalue
xvalue
- то же самоеprvalue
- не обладают идентичностью, могут быть перемещены
Да кто такие эти ваши glvalue и rvalue
Свойства glvalue
:
- Могут быть неявно преобразованы в
prvalue
- Могут быть полиморфными (?)
- Не могут иметь тип
void
(из-за наличия идентичности) - Может быть неполным типом (
incomplete type
), где это разрешено выражением
Свойства rvalue
:
- Нельзя взять адрес оператором & (из-за отсутствия идентичности)
- Не могут находиться слева от оператора =
- Могут использоваться для инициализации
const lvalue
-ссылки илиrvalue
-ссылки, при этом время жизни объекта расширяется до времени жизни ссылки - Если в overload resolution пришли две функции, одна из которых принимает
const-lvalue
-ссылку, а другаяrvalue
-ссылку, то выберется вторая (то есть, например, если move-конструктор определен, то он предпочтительнее)
Про lvalue
Свойства:
- Все свойства
glvalue
- Можно взять адрес оператором &
- Модифицируемые
lvalue
могут стоять слева от оператора присваивания - Могут использоваться для инициализации
lvalue
-ссылки
Примеры lvalue
:
- Имя переменной, функции или поле класса любого типа. Даже если переменная является
rvalue
-ссылкой, имя этой переменной в выражении являетсяlvalue
(другими словами, именованнаяrvalue
-ссылка являетсяlvalue
-значением, но это все ещеrvalue
-ссылка) - Вызов функции или оператора, возвращающего
lvalue
-ссылку - Преобразование к типу
lvalue
-ссылки - Результат встроенных операторов присваивания, составных операторов присваивания (
=
,+=
,/=
и т.д.), встроенных преинкремента и предекремента, встроенных операторов разыменования указателя - Результат встроенного оператора обращения по индексу от
lvalue
-массива - Строковый литерал, например,
"Hello world!"
(можно взять его адрес тоже)
Про prvalue
Свойства:
- Все свойства
rvalue
- Не могут быть полиморфными (?)
- Не могут быть неполного типа
- Не могут иметь абстрактный тип или быть массивом элементов абстрактного типа
Примеры prvalue
:
- Литерал (кроме строкового), например,
42
,false
илиnullptr
- Вызов функции или оператора, который возвращает не ссылку
- Результат преобразования к нессылочному типу
- Результат встроенных операторов постинкремента и постдекремента, встроенных математических, логических операторов, операторов сравнения, взятия адреса
- Указатель
this
- Элемент перечисления (enum)
- Нетиповой параметр шаблона, если он не является классом
- Лямбда-выражение (пример,
[](int x) { return x*x; }
)
Про xvalue
Свойства:
- Все свойства
glvalue
- Все свойства
rvalue
Примеры xvalue
:
- Вызов функции или встроенного оператора, возвращающего
rvalue
-ссылку, напримерstd::move(x)
- Результат преобразования к
rvalue
-ссылке - Нестатический член класса от
rvalue
-объекта
std::move()
Функция std::move()
не выполняет никаких перемещений, о чем порой заблуждаются - это просто приведение lvalue
-аргумента к rvalue
-ссылке.
Может быть реализована следующим образом:
template <class T>
constexpr remove_reference_t<T>&& move(T&& arg) noexcept {
return static_cast<remove_reference_t<T>&&>(arg);
}
То есть это просто обертка над static_cast
, которая «убирает» ссылку у переданного аргумента с помощью remove_reference_t
и, добавив &&, преобразует тип в rvalue
-ссылку.
В примере выше T&& - не rvalue
-ссылка, а универсальная ссылка, о чем будет сказано в отдельном разделе.
Еще одна типичная ошибка при использовании std::move()
:
std::string get_my_string(const size_t index) {
std::string my_string;
// *do something*
return std::move(my_string); // wrong!
}
Работать это будет, но это неэффективно. Лучше писать без std::move()
- копирования не случится из-за оптимизации RVO
и его друзей (когда они возможны).
Заимствования:
Категории выражений в C++ / Хабр (habr.com)
std::move vs. std::forward / Хабр (habr.com)
Reference collapsing rule
Еще до С++11 ввели правило, что ссылка на ссылку - это ссылка без вложенности. В связи с введением rvalue
-ссылок правило пришлось дополнить.
(A&)& -> A&
(A&)&& -> A&
(A&&)& -> A&
(A&&)&& -> A&&
Так же в связи с появлением нового типа ссылок стало необходимо распознавать, ссылка какого типа пришла в функцию. Разработчики решили даром синтаксис не терять и не городить новых конструкций, поэтому написание T&&
от шаблонного типа стало означать новую фичу.
Важный момент! Помните, что
int a = 42;
int& b = a; // lvalue-ссылка, имеющая lvalue категорию
const int& b = a; // то же самое
int&& f() {
return 42;
}
f(); // rvalue-ссылка, имеющая xvalue категорию
int&& b = std::move(a); // rvalue-ссылка, имеющая lvalue категорию
int g(int&& a) { // a - rvalue-ссылка, имеющая lvalue категорию
return a;
}
Универсальная ссылка
В C++11 правила вывода шаблонных параметров были определены специальным образом, который позволил сохранять информацию о том, ссылка какого типа в функцию передавалась.
template <typename T>
void g(T&& a) {
f(a);
}
int main() {
g(42); // rvalue: T -> int, void g(int&&)
int a;
g(a); // lvalue: T -> int&, void g(int&)
}
Шаблонная "rvalue
"-ссылка ведет себя по-разному в зависимости от того, что в нее передали - она становится либо lvalue
-ссылкой, либо rvalue
-ссылкой.
Реализуется компилятором это тривиально: создаются обе версии, если нужно.
На примере выше можно передавать в g(T&&)
любой тип, и он прикастуется к ссылке определенного типа. Но есть подвох: как ни крути тип ссылки в рантайме мы все-таки не знаем, а в вызовах f(A&&)
из g(T&&)
вообще будет присутствовать только версия, принимающая lvalue
-ссылку.
Почему? Так как T&& a
- именованная ссылка, значит она имеет категорию lvalue
, значит тип аргумента будет (T&&)& -> T&, либо (T&)& -> T& по правилу схлопывания ссылок. Для того, чтобы сохранять информацию о типе ссылки на уровне компиляции, придумали std::forward
.
std::forward
Использование Perfect forwarding
позволяет сохранять тип ссылки на уровне компиляции.
void bar(int& v) {
std::cout << "lvalue";
}
void bar(int&& v) {
std::cout << "rvalue";
}
template <typename T>
void foo(T&& v) {
bar(v);
}
template <typename T>
void foo2(T&& v) {
bar(std::forward<T>(v));
}
int a = 42;
foo(a); // out: lvalue
foo(42); // out: lvalue
foo2(a); // out: lvalue
foo2(42); // out: rvalue
Часто std::forward
применяется вместе с variadic templates.
template<typename... Args>
void f(Args&&... args) {
g(std::forward<Args>(args)...);
}
Наследование и переопределение
Внимание: наследование классов иногда влечет за собой использование виртуального деструктора. Рекомендуется к прочтению ниже.
Представим, что у нас есть такой код:
struct vehicle {
void beep() {
std::cout << "vehicle does beep-beep\n";
}
};
struct bus : vehicle {
void beep() {
std::cout << "bus does beep-beep\n";
}
};
int main() {
bus b;
b.beep();
vehicle& v = b;
v.beep();
}
Люди, пишущие код на Java, привыкли к тому, что переопределение метода сохраняется при приведении класса-потомка к более базовому классу. Поэтому они могут ожидать, что код дважды вернет bus does beep-beep
, но по умолчанию в С++ это не так.
Правильный ответ такой:
bus does beep-beep
vehicle does beep-bepp
Добавление в потомке метода с сигнатурой, уже определенной ранее в классе-родителе, не переопределяет методы базового класса. Можно в каждом потомке писать метод void foo(int, int)
, и полученный объект действительно будет все их содержать.
Как вызвать определенный метод, если таких в цепочке наследования было несколько?
- через приведение к базовому типу
- через квалифицированный поиск (смотри
namespaces, aliases, adl
)
Пример квалифицированного поиска:
struct vehicle {
void beep() {
std::cout << "vehicle does beep-beep\n";
}
};
struct bus : vehicle {
void beep() {
std::cout << "bus does beep-beep\n";
}
};
struct liaz : bus {
void beep() {
std::cout << "liaz does beep-beep\n";
}
};
int main() {
liaz l;
l.beep();
l.bus::beep();
l.vehicle::beep();
}
Вывод:
liaz does beep-beep
bus does beep-beep
vehicle does beep-beep
Компилятор будет искать вызываемый метод начиная с того класса в цепочке наследования, на котором он был вызван, переходя при неудаче к предкам. То есть необязательно вызывать метод непосредственно на том классе, в котором он объявлен - можно запускать поиск и с потомков. Но есть нюанс:
Множественное наследование
В С++ классам разрешено иметь более одного родителя:
struct platform {
void f() {}
};
struct body {
void f() {}
};
struct vehicle : platform, body {};
Но что будет, если теперь запустить поиск метода void f()
из класса vehicle?
int main() {
vehicle v;
v.f(); // ???
}
Мы получим ошибку компиляции, так как в overload resolution придут оба кандидата, как глубоко бы не был вложен метод f()
в цепочке наследования, поэтому здесь не обойтись без приведения к нужному классу-родителю или без квалифицированного поиска.
Внимание: даже если один из родителей будет наследоваться приватно, все равно будет получена ошибка компиляции.
Все те же правила применимы к полям классов.
Виртуальные методы (virtual и override)
Внимание: использование виртуальных функций зачастую требует знания dynamic_cast
. Рекомендуется к прочтению ниже.
Добиться похожего в Java переопределения методов можно с помощью ключевого слова virtual
рядом с нужным методом.
struct vehicle {
virtual void beep() {
std::cout << "vehicle does beep-beep\n";
}
};
struct bus : vehicle {
void beep() {
std::cout << "bus does beep-beep\n";
}
};
int main() {
bus b;
b.beep();
vehicle& v = b;
v.beep();
}
Теперь начиная с класса vehicle
и далее по потомкам метод beep
всегда будет вызывать последнее переопределение:
bus does beep-beep
bus does beep-beep
Для классов, являющихся предками vehicle
сохраняется обычное поведение.
Было бы неудобно писать один раз где-то далеко virtual
и никак о нем не вспоминать при дальнейшей разработке, поэтому можно либо писать его и во всех последующих переопределениях, либо писать ключевое слово override
, которое ничего не делает, кроме проверки компилятора на то, что данный метод действительно является виртуальным (зато сразу понятно, что следует от него ожидать).
struct vehicle {
virtual void beep() {}
};
struct bus : vehicle {
void beep() override {}
};
Квалифицированный поиск при точном указании класса-родителя, у которого нужно вызывать метод вызывает именно этот метод и не обращает внимание на виртуальность. Если в указанном родителе метода не было, то компилятор так же, как и раньше, будет идти по предкам и искать, кого бы вызвать. При нахождении такого метода виртуальность тоже будет проигнорирована.
Использование виртуальных функций не дается за бесплатно. Каждый их вызов - потенциальные прыжки по указателям, поскольку для поддержания такого поведения необходима дополнительная структура, де-факто являющаяся так называемой таблицей виртуальных функций (vtable).
Отсюда мемы, что в Java все функции - виртуальные, и что "этот господин за все заплатит".
Виртуальный деструктор, ошибка разрушения базового класса
Рассмотрим следующий код:
struct vehicle {
~vehicle() {
std::cout << "vehicle destroyed\n";
}
};
struct bus : vehicle {
~bus() {
std::cout << "bus destroyed\n";
}
};
int main() {
bus b;
}
Он является корректным: мы создали машину, затем автобус, а достигнув конца блока main
разрушили сначала автобус, затем машину. Но что будет со следующим кодом?
vehicle* v = new bus();
delete v;
Будет вызван только деструктор класса vehicle
, а память класса bus
утечет. Так происходит, поскольку компилятор не знает, что по указателю лежит не просто класс vehicle
. Может быть еще хуже, если класс bus
будет иметь в себе указатель на условный гигабайт данных, который мы тоже не удалим.
Для решения этой проблемы деструктор базового класса предлагается делать виртуальным:
struct vehicle {
virtual ~vehicle() {/* ... */}
};
struct bus : vehicle {
~bus() {/* ... */}
};
В таком случае удалению базового класса будет всегда приводить сначала к удалению производного, затем базового, что правильно.
Виртуальное наследование (virtual)
Рассмотрим следующий код:
struct base {
void f() {}
}
struct left : base {}
struct right : base {}
struct top : left, right {}
int main() {
top t;
t.f();
}
Здесь есть две проблемы:
- Классы
left
иright
оба имеют методvoid f()
, поэтому мы получим ошибку компиляции (но ведь для нас этот метод все равно один, в каком-то смысле!) - Класс
top
будет содержать в себе два независимых подклассаbase
. Это подразумевает, что у них будет отдельная память, можно отдельно изменять их поля и так далее.
Если нам не хочется иметь такое поведение, то мы можем дать команду компилятору на склеивание одинаковых базовых классов, которые были когда-то унаследованы виртуально, в один экземпляр:
struct base {
void f() {}
}
struct left : virtual base {}
struct right : virtual base {}
struct top : left, right {}
int main() {
top t;
t.f(); // no compile errors
}
При этом данная техника тоже не бесплатная: при обращении к полям/методам виртуально унаследованного класса будет совершаться прыжок по указателю.
Приведение типов
В С++ есть 5 способов приведения типов
- C-style cast
static_cast<T>
dynamic_cast<T>
reinterpret_cast<T>
const_cast<T>
C-style cast
Может приводить практически любой тип к любому (но, например, пользовательские типы без правил приведения не удастся кастануть). Его минусом является отсутствие проверки типов на совместимость, например, так можно привести указатель на int
к указателю на std::iostream
. Чаще всего не рекомендуется к использованию.
int *p = new int(5);
std::iostream *i = (std::iostream*) p;
static_cast
Предоставляет типобезопасное приведение на уровне компиляции. Умеет кастовать числа друг к другу, а также указатели и ссылки на классы вверх или вниз по цепочке наследования. Любой указатель может быть приведен к указателю на void
и обратно.
dynamic_cast
Необходим для приведения полиморфных типов (классов, которые имеют виртуальные функции, либо наследуют их от предков). Если приведение по указателю было неудачным, возвращает nullptr
. Если приведение по ссылке было неудачным, бросает std::bad_cast
. Так как этот вид каста работает в реалтайме, то его использование в качестве замены static_cast
является нецелесообразным из-за медленности, но, конечно, возможно.
reinterpret_cast
Используется для приведения несовместимых типов друг к другу. Например, для приведения указателя к числу (или наоборот), либо указателю к другому указателю или ссылке к другой ссылке. В отличие от C-style cast не умеет изменить cv-квалификаторы (const
и volatile
), как и предыдущие два каста.
const_cast
Единственный C++ каст, который умеет модифицировать cv-квалификаторы. А больше он ничего и не умеет. Зачастую нужен, чтобы просто снять const
.
Объявление шаблона
Объявление шаблонного класса или шаблонной функции в примере ниже:
template <typename T>
struct vector {
void push_back(T const &);
T const& operator[](size_t index) const;
template <typename U>
void g(T, U);
};
template <typename T>
void f(T&& a, T&& b) {
std::cout << a + b << std::endl;
}
Вместо T и U будет подставляться тот тип, который был указан в шаблонном параметре при использовании класса/функции. Также в большинстве случаев компилятор может вывести тип самостоятельно без явного его указания.
vector<int> v;
vector<double> v2;
v.template g<float>(42, 42.0);
v.g(42, 42.0); // same
f(1, 3); // 4
f(1.5, 2.5); // 4.0
Специализации
Специализация - это выделенная реализация для каких-то указанных типов. К примеру из примера выше для vector<std::string>
и void f(bool&&, bool&&)
я бы хотел иметь другое тело функции. Это пример надуманный, конечно.
template <>
struct vector<std::string> {
/* ... */
};
template <>
void f<bool>(bool&& a, bool&& b) {
std::cout << (a | b) << std::endl;
}
Специализацию можно вводить не целиком, а частично, оставляя свободными другие шаблонные параметры. В качестве примера рассмотрим простенькую реализацию std::conditional
.
template <bool Cond, typename IfTrue, typename IfFalse>
struct conditional;
template <typename IfTrue1, typename IfFalse1>
struct conditional<false, IfTrue1, IfFalse1> { // partial specialization
typedef IfFalse1 type;
};
template <typename IfTrue1, typename IfFalse1>
struct conditional<true, IfTrue1, IfFalse1> { // partial specialization
typedef IfTrue1 type;
};
Специализациям свойственен большее высокий приоритет при разрешениях перегрузок (overload resolution).
template <typename T>
struct vector<T*> {
/* ... */
}
vector<foo*> v; // выберется эта специализация
Пример неразрешимой перегрузки:
template <typename U, typename V>
struct mytype {};
template <typename U, typename V>
struct mytype<U*, V> {};
template <typename U, typename V>
struct mytype<U, V*> {};
mytype<long*, double*> f;
Мы получим ошибку, так как есть два равноправных кандидата. Исправить это можно определением еще одной, более подходящей специализации:
template <typename U, typename V>
struct mytype<U*, V*> {};
Ошибка разделения на объявление и реализацию
Давайте представим, как мы могли бы писать код с шаблонными функциями, используя разделение на объявление и реализацию, как полагается.
// util.h
template <typename T>
void f(T&, T&);
// util.cpp
template <typename T>
void f(T& a, T& b) {
std::cout << a + b << std::endl;
}
// main.cpp
#include "util.h"
int main(){
int a, b;
f(a, b);
}
Но в данном примере, к сожалению, мы получим ошибку компиляции. Так происходит, потому что генерация и подстановка кода шаблонов (инстанцирование) происходит до линковки и после компиляции каждой отдельной единицы трансляции. Компилятор, обрабатывая util.cpp
, не знает о том, что кто-то будет вызывать f(int, int)
в других единицах трансляции.
На самом деле все шаблонные функции неявно являются inline
, поэтому их реализацию можно сразу писать в .h
файле.
Инстанцирование
В стандарте прописано, что инстанцирование происходит только когда это необходимо. При этом компилятор может делать это в конце единицы трансляции.
В следующем примере приводится случай, который это показывает:
template <typename T>
struct foo {
T* a;
void f(){
T a;
}
};
int main() {
foo<void> a; // так скомпилируется
a.f(); // а так нет, ошибка из-за void a
}
Генерация и подстановка по требованию, так сказать. С классами работает аналогично: полное тело класса не подставляется, если не требуется. Пример:
template <typename T>
struct foo {
T a;
};
int main() {
foo<void>* a; // так скомпилируется
a->a; // а так нет, опять ошибка из-за void a
}
Явное инстанцирование
Пусть мы не хотим, чтобы одни и те же лишние инстанцирования были в разных единицах трансляции. Чтобы этого избежать, можно делать так:
template <typename T>
void foo(T) {}
template void foo<int>(int); // генерирует тело функции в этом месте
template void foo<float>(float);
template void foo<double>(double);
Подавление инстанцирования
Пусть мы знаем, что функции уже где-то инстанцированы и мы не хотим лишних:
extern template void foo<int>(int);
extern template void foo<float>(float);
Выдаём тело наружу и говорим, что уже проинстанцировано. main
не будет пытаться инстанцировать функцию, так как увидит extern
и будет работать соответствующе.
Теперь зная про шаблоны и специализации можно творить всякую магию:
Подсчет факториала в compile-time
Осторожно, при отрицательных значениях компилятор может надолго зависнуть.
template <int T>
struct factorial {
static const int result = T * factorial<T - 1>::result;
};
template <>
struct factorial<0> {
static const int result = 1;
};
static_assert(factorial<0>::result == 1);
static_assert(factorial<3>::result == 6);
static_assert(factorial<5>::result == 120);
Заимствования:
cpp-notes/11_templates.md at master · lejabque/cpp-notes (github.com)
Parameter pack
В следующую функцию можно передать ноль или более различных аргументов любого типа.
template<typename... Args>
void f(Args&&... args) {
// ...
}
Как теперь можно работать с аргументом args
?
Можно развернуть его в список аргументов другой функции, как если бы мы перечислили аргументы через запятую.
template<typename... Args>
void f(Args&&... args) {
std::cout << sum(args...);
}
Теперь если мы вызовем f(1, 1.f, '1')
, то в консоль выведется результат sum(1, 1.f, '1')
.
Parameter pack можно использовать и для более хитрых вещей, например, в следующем примере:
template<typename... Args>
void f(Args&&... args) {
std::cout << sum((args * 2)...);
}
Конструкция ((args * 2)...)
развернется в (a1 * 2, a2 * 2, a3 * 2)
.
Раскрывать parameter pack можно с помощью, например, рекурсии:
int sum(int t) {
return t;
}
template <typename... Tail>
int sum(int t, Tail... tail) {
return t + sum(tail...);
}
template<typename... Args>
void f(Args... args) {
std::cout << sum(args...);
}
f(1, 2, 3); // out: 6
С помощью variadic templates, type_traits
и SFINAE
можно делать умопомрачительные вещи (в том числе в compile-time), но мы опустим этот момент.
Fold expressions (C++17)
Синтаксис:
- ( pack_name op ... )
- ( ... op pack_name )
- ( pack_name op ... op init )
- (init op ... op pack )
где pack_name - имя parameter pack, op - оператор, init - начальное значение.
Во что эти конструкции разворачиваются:
- ( E op ... ) -> ( E1 op (... op ( En-1 op En )))
- ( ... op E) -> ((( E1 op E2 ) op ...) op En )
- ( E op ... op I ) -> ( E1 op (... op ( En op I )))
- ( I op ... op E ) -> ((( I op E1 ) op ...) op En )
Битовое И переменного числа аргументов
template <typename... Args>
bool all(Args... args) {
return (... && args);
}
static_assert(all(true, true, false) == false);
static_assert(all() == true); // ?
Для раскрытия parameter pack длины 0 некоторые операторы имеют значения по умолчанию:
- Логическое И ->
true
- Логическое ИЛИ ->
false
- Оператор запятой
,
->void()
(?)
Вывод в консоль переменного числа аргументов
template<typename... Args>
void print(Args&&... args) {
(std::cout << ... << args) << '\n';
}
Исключения
Исключительная ситуация - следствие вызова операции с ошибкой, которая не может или не должна быть проигнорирована.
Например, деление на ноль вне типа double
. К чему это должно привести? Непонятно, поэтому кидаем исключение.
Другой пример: dynamic_cast
не смог привести тип к другой ссылке. Что нужно вернуть, чтобы принимающий знал, что операция завершилась неудачей? Нулевой ссылки не бывает, значит, кидаем исключение.
Для генерации исключения в C++ используется слово throw
. Бросать можно практически все, что угодно (можно сказать, все, что можно сконструировать). Где будет выделена память под объект, который будем бросать - не специфицировано.
Для отлавливания исключений используем блоки try
и catch
.
struct A {};
struct B : A {};
try {
// бросает B
f();
} catch (B b) {
// так можно
} catch (const B& b) {
// так можно
} catch (B&& b) {
// ошибка компиляции
} catch (const A&) {
// так можно
} catch (A a) {
// так можно, но осторожно
} catch (...) {
// так можно, ловит все
}
Поиск обработчика при появлении исключения в блоке try
идет последовательно, в объявленном разработчиком порядке, поэтому рекомендуется писать обработчики от частного к общему.
Если обработчик не был найден, исключение покинет блок try
, и стек продолжит разворачиваться.
Конструкцию throw
можно писать по-разному:
struct A {};
struct B : A {};
void f() {
throw B(); // B
throw new B(); // B*
}
try {
// бросает B
f();
} catch (B b) {
throw b;
// new B1 will be copied on catch(B b)
// new B2 will be copied on throw
// B2 will be thrown
// B and B1 will be destroyed
} catch (B b) {
throw;
// new B1 will be copied on catch(B b)
// B1 will be thrown
// B will be destroyed
} catch (const B& b) {
throw b;
// new B1 will be copied on throw
// B1 will be thrown
// B will be destroyed
} catch (const B& b) {
throw;
// B will be thrown
} catch (const A&) {
throw;
// B will be thrown
} catch (A a) {
throw;
// B will be thrown (check on gotbolt, i'm not sure)
} catch (...) {
throw;
// caught object will be thrown
}
noexcept
При использовании после объявления функции ключевое слово noexcept
разворачивается в конструкцию noexcept(true)
, означающую "эта функция никогда не бросает исключения".
Гарантия с нашей стороны позволяет компилятору генерировать более компактный код. Однако, если мы нарушим гарантию, и исключение будет брошено из noexcept
функции, то мгновенно произойдет вызов std::terminate
и по умолчанию программа аварийно завершится без запуска каких-либо деструкторов.
void f() noexcept {}
void g() noexcept {
throw "oops";
} // calls std::terminate()
В конструкцию noexcept(expr)
после объявления функции можно писать любое статическое булево выражение. Таким образом гарантия исключений шаблонных функций может, например, зависеть от переданного в них типа:
template <typename T>
void foo(T&& t) noexcept(std::is_trivially_destructible_v<T>);
В другом случае noexcept(expr)
служит булевым оператором, проверяющим, является ли переданная в unevaluated
-контексте функция noexcept
-функцией.
struct A {
~A() {
// non-trivial
}
};
static_assert(noexcept(foo<A>(std::declval<A>())) == 0);
Часто используется в таком виде:
template <typename T>
void bar(T&& t) noexcept(noexcept(f<T>(std::declval<T>())));
Исключения в деструкторах
Деструкторы, начиная со стандарта C++11, неявно помечены как noexcept
, то есть им не разрешается бросать исключения вообще, иначе произойдет мгновенный вызов std::terminate
.
Статическая и динамическая инициализация
Известно, как можно ускорить свою программу до предела - считать что-то нужное аж на этапе компиляции. Рассмотрим следующий пример:
int f() {
int result;
std::cin >> result;
return result;
}
int a = 42;
int b = f();
Ничто не мешает переменной a
быть подсчитанной на этапе компиляции, поскольку мы знаем, чему равно выражение справа. С другой стороны, переменная b
не может быть посчитана на этапе компиляции по очевидным причинам - ей нужен пользовательский ввод.
Говорят, что переменная a
инициализируется статически (на этапе компиляции), а переменная b
- динамически, в runtime.
Динамическая инициализация глобальных переменных происходит в момент запуска программы до входа в функцию main
, а разрушаются они после выхода из main
, при этом имеются недостатки:
- При запуске программы тратится время на инициализацию
- В одной единице трансляции переменные инициализируются сверху вниз, а между разными порядок не гарантирован. Это становится проблемой, когда инициализация переменной в одной единице трансляции обращается к переменной в другой, а та ещё не инициализирована.
- Исключение при динамической инициализации глобальной переменной аварийно завершает программу, так как до входа в
main
его негде поймать.
Спецификатор constexpr
Спецификатор constexpr
можно приписывать к возвращаемому типу функций, методов, а также к переменным.
constexpr T f(...) {
/* ... */
}
constexpr T name = expr;
Для переменной это значит, что выражение справа обязано уметь считаться на этапе компиляции, иначе получим ошибку компиляции.
Для функции же это значит, что она может считаться как на этапе компиляции, так и в runtime (не будет выведена ошибка компиляции), если какой-то из переданных параметров не может быть посчитан в compile-time.
В constexpr
функции нельзя использовать выражения, не являющиеся константой времени компиляции, например, использование std::cin
или вызов не constexpr
-функции.
Простой пример использования ниже:
int sum(int a, int b) {
return a + b;
}
constexpr int c_sum(int a, int b) {
return a + b;
}
constexpr int a1 = c_sum(5, 12); // посчитается при компиляции
constexpr int a2 = sum(5, 12); // ошибка: sum() не является constexpr выражением
int a3 = c_sum(5, 12); // будет посчитано в runtime
int a4 = sum(5, 12); // так же
Сейчас constexpr
позволяется очень многое, и компилятору нужно тщательно следить за тем, чтобы не допустить UB. К примеру, он подскажет о переполнении чисел во время подсчета или делении на ноль, а также о пределе вложенности вызовов.
error: constexpr variable 'foo' must be initialized by a constant expression
constexpr int foo = 13 + 2147483647;
^ ~~~~~~~~~~~~~~~
С++ понемногу становился интерпретируемым языком, если можно так сказать. Но сначала ограничений было много и программировать можно было только в стиле, похожем на функциональный.
Вот так можно было считать факториал в compile-time вместо шаблонов.
constexpr int factorial(int n) {
return n == 0 ? 1 : n * factorial(n - 1);
}
static_assert(factorial(5) == 120);
Затем добавили возможность использовать константные ссылки (принимать и возвращать), if
, typedef
, using
и static_assert
:
template <typename T>
constexpr T self(const T& a) {
return *(&a);
}
template <typename T>
constexpr const T& self_ref(const T& a) {
return *(&a);
}
constexpr auto a1 = self(123); // OK
constexpr auto a2 = self_ref(123); // OK
Также разрешили объявлять типы (class
, enum
и т.д.) и возвращать void
, а еще создавать инстансы классов с constexpr
-конструктором, имеющих также тривиальный деструктор.
Главной проблемой оставался запрет на изменение какой-либо переменной в контексте constexpr
-функций. Таким образом мы не имели for
и while
. Но вскоре и это разрешили делать, но с небольшой ремаркой.
Изменять объект в рамках вычисления constexpr
-выражения можно только если он был создан в процессе вычисления этого выражения:
constexpr int f(int a) {
int n = a;
++n;
return n * a;
}
int k = f(4); // OK
// переменная 'n' внутри 'f' может быть
// модифицирована, так как она была создана
// во время вычисления выражения
constexpr int k2 = ++k; // ошибка.
// нельзя модифицировать переменную k, потому
// что она была создана до начала вычисления
// выражения
Позднее разрешили делать constexpr
-лямбды.
Начиная с С++20 в constexpr
-контексте можно использовать std::vector
и некоторые его методы, например, push_back
, operator[]
или size
.
В будущем могут разрешить и другие STL контейнеры (да прибудет с вами constexpr
-аллокатор), но с ремаркой, что такую память нужно освобождать по завершению вычисления constexpr
-выражения. Впрочем, есть предложения уметь не просто освобождать ее, а, например, еще возвращать (non-transient constexpr allocations using propconst).
Таким образом мы движемся к интерпретатору на уровне компилятора.
Бонус: подсчет факториала в compile-time с сохранением результатов для использования в runtime. Как написать это на шаблонах мне не известно.
#include <iostream>
#include <array>
template <size_t N>
constexpr std::array<int, N> factorials() {
std::array<int, N> a{1};
for (size_t i = 1; i < N; ++i) {
a[i] = a[i - 1] * i;
}
return a;
}
int main() {
constexpr auto f = factorials<10>();
for (int i : f) {
std::cout << i << std::endl;
}
}
Спецификатор consteval (C++20)
Как проверить, что значение constexpr
-функции точно будет вычислено в compile-time?
Можно присвоить ее в constexpr
-переменную, потому что, как мы знаем, выражение справа от такой переменной обязано быть constexpr
.
Но есть и более прямой способ сказать, что функция может вызываться только и только на этапе компиляции. Для этого ввели спецификатор consteval
.
consteval int sqr(int n) {
return n*n;
}
constexpr int r = sqr(100); // OK
int x = 100; // non-constexpr variable
int r2 = sqr(x); // error: x is not usable in a constant expression
if constexpr (C++17)
Позднее появился особенный вариант для if
, который позволил разгрузить страшный шаблонный код, который пишут, используя SFINAE
.
Конструкция if constexpr
требует, чтобы проверяемое условие было constexpr
выражением, и делает бранчинг на этапе компиляции, опуская из кода ветку, которая не подходит условию. Таким образом данный код будет компилироваться.
struct A {
static constexpr bool has_foo = true;
void foo();
};
struct B {
static constexpr bool has_foo = false;
void bar();
};
template <typename T>
void f(T obj) {
if constexpr (T::has_foo) {
obj.foo();
} else {
obj.bar();
}
}
Очевидно, что с использованием просто if
произойдет ошибка компиляции. Класс A не имеет метода bar
, а класс B не имеет метода foo
.
Еще раз стоит отметить, что данная конструкция не является заменой обычному if
, она принимает только constexpr
-выражения.
Пример кода, который не скомпилируется:
constexpr bool is_even(int a) {
if constexpr (a & 1) {
return false;
} else {
return true;
}
}
Заимствования:
Дизайн и эволюция constexpr в C++ / Хабр (habr.com)
Спецификатор constexpr в C++11 и в C++14 / Хабр (habr.com)
cpp-notes/22_constexpr.md at master · lejabque/cpp-notes (github.com)
decltype
Иногда возвращаемый тип не хочется писать руками:
int f();
??? g() {
return f();
}
В C++11 появилась конструкция, позволяющая по выражению узнать его тип:
int main() {
decltype(2 + 2) a = 42; // int a = 42;
}
decltype(f()) g() {
return f();
}
decltype
сделан так, чтобы его было удобно использовать для возврата значений:
int foo();
int& bar();
int&& baz();
// decltype(foo()) -> int
// decltype(bar()) -> int&
// decltype(baz()) -> int&&
То есть decltype(expr)
возвращает следующие типы:
- type для
prvalue
- type& для
lvalue
- type&& для
xvalue
Также ключевое слово decltype
применимо для переменных и членов класса.
struct x {
int a;
};
int main() {
decltype(x::a) y; // int y
int a;
decltype(a) b = 42; // int b = 42
decltype((a)) c = a; // int & c = a
}
Последнее работает так, потому что (a)
- это выражение, и оно имеет тип int&, а a
- это имя переменной.
declval
Иногда хочется узнать тип чего-то, что зависит от шаблонных аргументов функции, но просто сделать это с помощью decltype
не получится, так как тогда компилятор встречает обращение к параметру функции, когда еще не дошел до его объявления.
Для этого есть синтаксическая конструкция declval
:
int foo(int);
float foo(float);
// compile error
template <typename T>
decltype(foo(t)) f(T&& t) {
return foo(std::forward<T>(t));
}
// nice.
template <typename T>
decltype(foo(declval<T>())) f(T&& t) {
return foo(std::forward<T>(t));
}
Сигнатура declval
могла бы выглядеть как-то так:
template <typename T>
T declval();
Для declval
не нужно тело функции, так как decltype
не генерирует машинный код и считается на этапе компиляции.
В языке есть несколько мест с похожей логикой - например, sizeof
. Такие места называются unevaluated contexts.
При использовании сигнатуры, как выше, могут возникать проблемы с неполными типами (просто не скомпилируется). Это происходит из-за того, что если функция возвращает структуру, то в точке, где вызывается функция, эта структура должно быть complete типом. Чтобы обойти это, делают возвращаемый тип rvalue-ссылкой:
template <typename T>
T&& declval();
Trailing return types
Чтобы не писать declval
, сделали возможной следующую конструкцию:
template <typename... Args>
auto f(Args&&... args) -> decltype(foo(std::forward<Args>(args)...)) {
return foo(std::forward<Args>(args)...);
}
То есть компилятор, натыкаясь на decltype
уже знает аргументы, которые передаются в функцию, и может на них опираться. Очень удобно, конечно (сарказм).
auto
Можно заметить, что в return
и decltype
повторяется одно и то же выражение. Во избежание копипасты добавили возможность писать decltype(auto)
.
int main() {
decltype(auto) b = 2 + 2; // int b = 2 + 2;
}
template <typename... Args>
decltype(auto) f(Args&&... args) {
return foo(std::forward<Args>(args)...);
}
Возникает вопрос, а зачем нам вообще decltype
, можно ли заменить его на просто auto
? Для этого стоит сказать о том, как работает auto
.
Правило вывода типов у auto
почти полностью совпадает с тем, как выводятся шаблонные параметры. Поэтому auto
отбрасывает ссылки и cv-квалификаторы.
int& bar();
int main() {
auto c = bar(); // int c = bar()
auto& c = bar(); // int& c = bar()
}
Поэтому обычный auto
в возвращаемом типе отбрасывает ссылки с cv-квалификаторами, поэтому чаще всего нам нужен именно decltype(auto)
.
Еще стоит сказать, что если у функции несколько инструкций return
, который выводятся в разные типы, то использовать decltype
и auto
нельзя:
// compile error
auto f(bool flag) {
if (flag) {
return 1;
} else {
return 1u;
}
}
Заимствования:
cpp-notes/18_decltype_auto_nullptr.md at master · lejabque/cpp-notes (github.com)
SFINAE
SFINAE - substitution failed is not an error. Для того, чтобы сказать, что это такое, необходимо вспомнить, как разрешаются перегрузки функций.
void f(int, std::vector<int>);
void f(int, int);
void f(double, double);
void f(int, int, char, std::string, std::vector<int>);
void f(std::string);
f(1, 2);
- Для сопоставления
f(1, 2)
с конкретной функцией компилятор отправит все функции с названиемf
в overload resolution. - Далее из списка исчезают кандидаты, у которых количество параметров не может совпасть с теми, что представлены в вызове.
- Потом отсекаются функции, типы параметров которых отличаются от переданных аргументов и для которых нет неявного преобразования.
- После этого идут несложные, но многословные правила поиска лучшей перегрузки, и побеждает
f(int, int)
, так как она не требует преобразований аргументов.
Если бы обе подходили одинаково хорошо, то вызов был бы двусмысленным, о чём компилятор сообщил бы. Так, в общих чертах, и работает перегрузка методов в C++.
Добавим шаблонные функции!
template<typename T>
void function(T, T);
Теперь несколько изменится первая стадия:
Если компилятор встречает шаблонную функцию, имя которой совпадает с именем вызова, тогда он пытается вывести аргументы шаблона, на основании аргументов переданных в вызов (argument deduction
).
И если все аргументы удаётся вывести, то шаблонная функция с выведенными аргументами добавляется в список кандидатов функций.
В нашем примере в конце все равно останется только нешаблонная f(int, int)
, так как при прочих равных нешаблонная функция всегда сильнее шаблонной.
А что происходит, если вывести аргументы шаблона не удалось? Тогда такая шаблонная функция просто не попадает в список overload resolution. Это и есть правило SFINAE
. Но следует понимать, что рассматривается исключительно сигнатура функции и ничего больше.
Поэтому если подстановка аргументов даёт корректную функцию с точки зрения её сигнатуры, и функция побеждает в перегрузке, а потом оказывается, что в теле функции есть какие-то проблемы, с которыми компилятор справится не может, то компиляция будет завершена ошибкой - это называется hard error
.
Примитивное SFINAE
Используя SFINAE мы можем получить рефлексию на этапе компиляции, узнавая свойства объектов, и в зависимости от этого разрешать или запрещать им использовать какие-то функции и прочее.
Следующая шаблонная шапка позволяет использовать функцию после нее только с типами, в которых объявлен тип с алиасом iterator
. Если такого алиаса в типе объявлено не будет, то произойдет ошибка вывода шаблонных аргументов и данная функция не попадет в список overload resolution.
template <typename T, typename = typename T::iterator>
Подобным образом в шаблонных параметрах можно объявлять и другие ограничения на используемые шаблонные типы.
О сигнатуре шаблонной функции
Многим известно, что возвращаемый тип в функции не является частью её сигнатуры, но это не так для шаблонных функций. Это позволяет использовать, например, std::enable_if
на возвращаемом типе.
Схематично, это выглядит так:
template <bool condition, class T = void>
struct enable_if;
template<class T>
struct enable_if<true, T> {
typedef T type;
};
То есть, при передаче в шаблон true
в структуре появляется поле type
по умолчанию типа void
, но если шаблон был вызван с параметром false
, то этого поля не будет, а его использование в сигнатуре функции или в шапке шаблона приведет к неудаче вывода, и эта перегрузка не будет включена в список кандидатов.
Напишем надуманный пример, использующий enable_if
в возвращаемом типе.
namespace {
struct tag;
}
template <typename T>
enable_if<std::is_trivially_destructible_v<T>>::type f(T);
template <typename T>
enable_if<!std::is_trivially_destructible_v<T>, tag>::type f(T);
template <typename T>
constexpr bool is_void_f = std::is_same_v<decltype(f(std::declval<T>())), void>;
int main() {
std::cout << is_void_f<int> << std::endl;
std::cout << is_void_f<std::string> << std::endl;
} // output: 1 0
Применение SFINAE в бою
В заметке про if constexpr
написан пример, позволяющий по члену класса has_foo
судить о наличии соответствующего метода в классе. Попробуем написать метафункцию, определяющую существование в классе, например, метода void foo(int)
с помощью новых знаний.
template<typename T>
struct has_foo {
static constexpr bool value = true; // сейчас придумаем, что здесь написать
};
Осталось придумать перегрузку, определяющие нужные нам свойства типа, и как получить из нее булевскую константу.
Во-первых создадим всеядную функцию-подложку (иногда говорят, fallback), которую компилятор выберет, если не подойдет полезная перегрузка, присваивающая каким-то образом в value
значение true
.
template<typename T>
struct has_foo {
struct dummy;
static dummy detect(...); // fallback
static constexpr bool value = true; // ладно-ладно, уже скоро придумаем!
};
Теперь придумаем детектор. Здесь нам придется воспользоваться конструкциями decltype
и std::declval
, которые могут вызываться на этапе компиляции и проверять свойства без реальных вызовов конструкторов, функций и прочего.
template<typename T>
struct has_foo {
struct dummy;
static dummy detect(...); // fallback
template<typename U>
static decltype(std::declval<U>().foo(42)) detect(const U&); // detector
static constexpr bool value = true; // теперь точно скоро!
};
Теперь осталось воспользоваться детектором, чтобы поставить правильное значение поля value
. Также упростим синтаксис для конечного пользователя.
template<typename T>
struct has_foo {
private: // скроем детали реализации
struct dummy;
static dummy detect(...);
template<typename U>
static decltype(std::declval<U>().foo(42)) detect(const U&);
public:
static constexpr bool value =
std::is_same<void, decltype(detect(std::declval<T>()))>::value;
};
template <typename T>
constexpr bool has_foo_v = has_foo<T>::value;
Использование:
struct check1 {
void foo(int);
};
struct check2 {};
int main() {
std::cout << has_foo_v<check1> << std::endl; // 1
std::cout << has_foo_v<check2> << std::endl; // 0
// или
std::cout << has_foo<check1>::value << std::endl;
std::cout << has_foo<check2>::value << std::endl;
}
Заимствования:
SFINAE. Как много в этом слове (scrutator.me)
SFINAE — это просто / Хабр (habr.com)
SFINAE — Википедия (wikipedia.org)
Что такое RAII
RAII (Resource Acquisition Is Initialization) значит, что при получении какого-либо ресурса, его инициализируют в конструкторе, а, поработав с ним в функции - корректно освобождают в деструкторе. Даже если в коде бросится исключение, то RAII-объект должен корректно уничтожить принадлежащий ему объект.
Существует три типа гарантии безопасности исключений:
- Базовая гарантия - при возникновении любого исключения в некотором методе, состояние программы должно оставаться согласованным. Это означает, не только отсутствие утечек ресурсов, но и сохранение инвариантов класса.
- Строгая гарантия - если при выполнении операции возникает исключение, то это не должно оказать какого-либо влияния на состояние приложения. Другими словами, строгая гарантия исключений обеспечивает транзакционность операций.
- Гарантия отсутствия исключений - ни при каких обстоятельствах функция не будет генерировать исключения.
std::shared_ptr
Осуществляет подсчет ссылок. Когда количество ссылок обнулится, хранимый объект уничтожится с помощью delete
, delete[]
или переданного в конструкторе deleter
'а.
Копируемый. Потокобезопасный на методы своего класса при вызовах к разным экземплярам shared_ptr
(отсюда следует, что счетчик ссылок атомарен), но может случиться data race при доступе к одному и тому же экземпляру shared_ptr
.
Из-за того, что каждое копирование shared_ptr
- дорогое удовольствие, лучше пользоваться его передачей по ссылке, где это возможно.
В конструкторе тип данных передаваемого указателя должен быть complete-типом.
Частая ошибка:
T *p = new T();
std::shared_ptr<T> p1(p);
std::shared_ptr<T> p2(p);
Такой код некорректен, так как у p1
и p2
разные счётчики ссылок, поэтому объект *p
удалится дважды. Чтобы этого не происходило, не нужно оборачивать один сырой указатель в shared_ptr
дважды - есть конструкторы копирования.
Aliasing constructor
Иногда возникает желание ссылаться с помощью shared_ptr
на объект и его мемберов. Наивное решение:
struct wheel {};
struct vehicle {
std::array<std::shared_ptr<wheel>, 4> wheels;
};
Проблема такого подхода в том, что при удалении vehicle
, wheel
остаются живы, пока на них кто-то ссылается.
Можем захотеть такое поведение: пока кто-то ссылается на составную часть объекта, основной объект жив. Для этого можно использовать для них общий счётчик ссылок.
struct wheel {};
struct vehicle {
std::array<wheel, 4> wheels;
};
void foo() {
std::shared_ptr<vehicle> v(new vehicle());
std::shared_ptr<std::array<wheel, 4>> w(v, &v->wheels);
store_for_later(w);
} // vehicle is still alive
В таком случае оба указателя отвечают за удаление объекта vehicle
(в зависимости от того, какой из указателей будет разрушен раньше), поэтому deleter
у них общий, кроме того в управляющем блоке хранится указатель на исходный объект, чтобы передать его в deleter
.
std::make_shared - зачем?
Потенциально одна аллокация вместо двух + cache-friendly - control block
и объект, которым владеем, лежат в одном куске памяти.
Кроме экономии аллокаций, make_shared
избавляет нас от необходимости следить за исключениями в new
. Пример кода:
bar(std::shared_ptr<mytype>(new mytype(1, 2, 3)),
std::shared_ptr<mytype>(new mytype(4, 5, 6)));
Так как порядок выполнения не задан, сначала может вызваться первый new
, затем второй, а потом только конструкторы shared_ptr
. В таком случае, если второй new
кинет исключение, то первый объект не удалится. make_shared
позволяет избежать этой ошибки.
std::enable_shared_from_this - зачем?
Мы уже сказали, что следующий код некорректен:
T *p = new T();
std::shared_ptr<T> p1(p);
std::shared_ptr<T> p2(p);
Точно такой же ошибкой, но в другой обертке, может стать и возврат из метода класса shared_ptr(this)
, если текущий объект уже находится под наблюдением shared_ptr
.
Пример хорошего и плохого кода:
#include <memory>
#include <iostream>
struct Good : std::enable_shared_from_this<Good> {
std::shared_ptr<Good> getptr() {
return shared_from_this();
}
};
struct Bad {
std::shared_ptr<Bad> getptr() {
return std::shared_ptr<Bad>(this);
}
~Bad() {
std::cout << "Bad::~Bad() called\n";
}
};
int main() {
// Good: the two shared_ptr's share the same object
std::shared_ptr<Good> gp1(new Good);
std::shared_ptr<Good> gp2 = gp1->getptr();
// Bad, each shared_ptr thinks it's the only owner of the object
std::shared_ptr<Bad> bp1(new Bad);
std::shared_ptr<Bad> bp2 = bp1->getptr();
} // UB: double-delete of Bad
std::weak_ptr
Этот умный указатель ходит в паре с shared_ptr
и может ссылаться только на объекты, которые захвачены каким-либо shared_ptr
.
Отсюда способ его получения - либо конструкторы от других weak_ptr
, либо конструктор от shared_ptr
.
Сами по себе weak_ptr
не влияют на счетчик, который ведут shared_ptr
, от которого зависит время жизни захваченного во владение объекта, однако счетчик слабых ссылок существует все равно, поскольку необходимо знать, когда нужно удалять control block
.
Имея экземпляр weak_ptr
можно получить shared_ptr
на ссылаемый объект с помощью метода weak_ptr::lock()
, который вернет экземпляр shared_ptr
, который ссылается на исходный объект (сделав +1 к счетчику, разумеется), если количество "сильных" ссылок еще не обнулено, и объект не удален, либо вернет пустой shared_ptr
.
Данный указатель является потокобезопасным в том же смысле, как shared_ptr
.
Использование weak_ptr
необходимо, в частности, чтобы иметь два объекта, ссылающихся на друг друга умными указателями, но при этом не вызывающих утечку памяти.
Приведение shared_ptr к указателям другого типа
В стандартной библиотеке реализованы все 4 вида кастов (static
, dynamic
, const
, reinterpret
) для shared_ptr
- они создают новый инстанс shared_ptr
, который хранит указатель, приведённый соответствующим кастом, и разделяет владение (счётчик ссылок) с исходным shared_ptr
(привет, aliasing constructor).
Внутри это выглядит так, на примере static_cast
:
template <class T, class U>
std::shared_ptr<T> static_pointer_cast(const std::shared_ptr<U>& r) noexcept {
auto p = static_cast<typename std::shared_ptr<T>::element_type*>(r.get());
return std::shared_ptr<T>(r, p);
}
dynamic_cast
выглядит немного иначе - в случае "неудачного каста" (который возвращает nullptr
) вернется пустой shared_ptr
.
std::unique_ptr
До внедрения в стандарт move-семантики (до С++11) разработчики могли хотеть в языке указатель, следовавший концепции RAII, но не разделяющий владение. Так появился auto_ptr
.
С приходом C++11 его пришлось пометить как deprecated
, поскольку его оператор присваивания работал ровным счетом как оператор перемещения сейчас, что могло приводить к непониманию и абсолютно не вписывалось в новые фишки стандарта. Так появился unique_ptr
.
unique_ptr
не хранит счетчик ссылок (а является полным владельцем переданного ему указателя), конструктор копирования и оператор присваивания у него запрещены, зато его можно мувать.
В отличие от shared_ptr
этому умному указателю не нужны дополнительные функции для кастования (опять же, потому что он является полным владельцем своего объекта), но ему так же можно настраивать deleter
и вызывать функцию make_unique()
, подобную таковой у первого указателя.
Заимствования:
cpp-notes/16_smart_pointers.md at master · lejabque/cpp-notes (github.com)
std::enable_shared_from_this - cppreference.com
Empty base optimization
Разрешает классу-предку иметь нулевой размер в байтовом представлении объекта.
struct empty {};
struct derived : empty {
int data;
};
static_assert(sizeof(empty) > 0);
static_assert(sizeof(derived) == sizeof(int));
В примере выше объект derived
будет иметь одинаковый адрес со своим предком.
Можно подумать, что для полей, состоящих пустого класса (как empty
) такая оптимизация тоже справедлива, но как бы не так.
struct derived {
empty e;
int data;
};
static_assert(sizeof(derived) == sizeof(int)); // compile error
Однако если класс действительно пустой, то его можно лишить своего адреса легально с помощью атрибута [[no_unique_address]]
. В случае, если класс пустым не был, этот атрибут будет проигнорирован.
struct derived {
[[no_unique_address]] empty e;
int data;
};
static_assert(sizeof(derived) == sizeof(int)); // OK!
Return value optimization
Нельзя опираться на то, что у возвращаемого по значению объекта будут вызваны конструкторы копирования, перемещения и/или деструктор, если он конструируется при вызове return
. Более того, они могут даже не понадобиться при компиляции.
mytype f() {
return mytype(1, 2, 3);
}
// компилится во что-то, похожее на:
void f(void* result) {
mytype_ctor(result, 1, 2, 3);
}
Если из функции возвращается prvalue
, то копия не создаётся и объект конструируется уже на месте - там, куда возвращается значение. Данная оптимизация записана в стандарте С++17 как Copy elision
и обязательна для компилятора.
Named return value optimization
Можно пойти дальше и ввести подобную оптимизацию для lvalue
. На данный момент она необязательна для компиляторов. Пример:
std::string f() {
std::string tmp;
for (;;) {
tmp += ...;
}
return tmp;
}
// можно разместить tmp уже на result-е, псевдокод:
void f(void* result) {
string_ctor(result);
for (;;) {
*result += ...;
}
}
Иногда NRVO
не может быть применено, когда мы не знаем, что должно возвращаться:
std::string g() {
std::string a("abc");
std::string b("def");
if (flag) {
return a;
} else {
return b;
}
}
Гарантированно нельзя использовать NRVO
в constexpr
-функциях и при инициализации глобальных, статических и thread-local переменных. Также NRVO
неприменимо с volatile
-объектами.
struct E {
constexpr E() = default;
E(const E&) = delete;
//E(E&&) = default; <-- uncomment to fix
};
constexpr E f() {
E e;
return e; // compile-error: E(const E&) deleted
}
E e = f();
Profile-guided optimization
В отличии от методов оптимизации, основанных исключительно на анализе исходного кода, позволяет использовать трассировку работы программы, собранной со специальным флагом, на основании которой компилятором принимается решение об оптимизации тех частей кода, которые вызывались чаще всего.
Крайне важно запускать программу с PGO на тех данных, с которыми ваша программа будет работать в реальном мире, иначе итоговая производительность может даже уменьшиться.
PGO может использовать следующие оптимизации:
- Inlining
- Virtual Call Suspection - условно-прямой (по условию) вызов определенной виртуальной функции в обход таблицы виртуальных функций
- Register Allocation - оптимизация распределения данных в регистрах
- Basic Block Optimization - помещать совместно вызываемые блоки кода в общую страницу памяти
И так далее, лучше глянуть официальную документацию.
После запуска программы со сбором статистики необходимо скомпилировать программу еще раз уже с учетом этой статистики.
Link-time optimization
LTO значит, что оптимизация будет происходить на этапе линковки.
Некоторые компиляторы (например, Clang) не переводят исходный код напрямую в ассемблер, а используют так называемый бэкенд, например, LLVM. Тогда при компиляции код сначала будет переводиться в байткод LLVM IR.
Компилятор LLVM будет оптимизировать полученный байткод во время встраивания кода функций в итоговый бинарник, и за счет снижения уровня абстракций у него это может получиться гораздо лучше, чем у компилятора на своем "верхнем" уровне (например, GCC).
Однако LTO в деле требует мощного компьютера при сборке проекта (в основном требуется больше оперативной памяти).
Из плюшек разделения процесса компиляции на фронтенд и бэкенд стоит отметить более лучшую переносимость кода. А в случае в LLVM, который используется уже очень много где, появляется еще одна фишка - LTO может работать при компиляции кода в единый бинарник, написанного на разных языках.
Заимствования:
RVO и NRVO в C++17 / Хабр (habr.com)
cpp-notes/14_move_rvalue.md at master · lejabque/cpp-notes (github.com)
LLVM — Википедия (wikipedia.org)
Лямбда-функция
Имеет следующий синтаксис:
auto lambda = [](int a, int b) {return a < b;}
Полученный объект по сути является структурой с известным только компилятору уникальным типом, имеющим operator()
с аргументами, которые передаются в лямбду.
Возвращаемый тип можно задать явно через trailing return types, либо довериться компилятору (равносильно типу auto).
Квадратные скобки здесь не просто для красоты - они используются для захвата переменных из контекста объявления лямбда-функции:
int a = 42;
// захват переменной по значению
auto mul = [a](int k) {return k * a;}
// захват переменной по ссылке
auto add = [&a](int k) {return k + a;}
// захват переменной по значению с присвоением нового имени (C++14)
auto sub = [b = a](int k) {return k - b;}
// захват переменной по ссылке с присвоением нового имени (C++14)
auto div = [&b = a](int k) {return k / b;}
Также лямбды умеют делать захват всего контекста по значению или ссылке:
int x, y;
[=](){} // все по значению
[=, &x](){} // все по значению, x - по ссылке
[&](){} // все по ссылке
[&, x](){} // все по ссылке, x - по значению
Захваченные по значению объекты являются константными, если лямбда-функция не имеет спецификатора mutable
. Также можно захватывать this
, как указатель на объект, где лямбда была объявлена, а можно захватывать *this
, как копию объекта.
Свойства лямбд
- могут копироваться и перемещаться
- не могут присваиваться
- лямбды без захвата могут конвертиться к указателю на функцию
- при копировании лямбды копируются все захваченные по значению переменные
- захват по ссылке не продлевает время жизни объектов. Такой код работает неправильно:
auto foo() {
std::vector<int> v;
return [&v]() {
// ...
};
} // v уничтожится при выходе из функции, обращение внутри лямбды - UB
Начиная с C++20 лямбды могут иметь шаблонные параметры:
int main() {
auto less = []<typename T>(T a, T b) {return a < b;};
bool val = less(42, 43);
}
Рекурсивный вызов лямбды
Пример ниже выдаст ошибку компиляции:
auto factorial = [&factorial](int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
};
error: use of 'factorial' before deduction of 'auto'
, что говорит нам о том, что тип auto
еще не был выведен, поэтому пользоваться им нельзя.
Вариант 1
std::function<int(int)> factorial;
factorial = [&](int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
};
std::cout << factorial(4);
Вариант 2
auto factorial = [](int n, auto&& f) -> int {
return (n <= 1 ? 1 : n * f(n - 1, f));
};
std::cout << factorial(4, factorial);
Заимствования:
cpp-notes/19_lambdas_type_erasure.md at master · lejabque/cpp-notes (github.com)
std::function
Пусть void(int,int)
- это тип функции, принимающей два инта и возвращающей ничего, тогда мы можем похранить ее в std::function
следующим образом.
void print(int a, int b) {
std::cout << a << " " << b << endl;
}
std::function<void(int, int)> func = print;
func(1, 2); // output: 1 2
Похранить функции с другой сигнатурой мы тоже, конечно, можем.
С помощью function
мы так же можем хранить и лямбды (и любой другой функциональный объект).
void f(bool flag) {
std::function<void(int,int)> func;
if (flag) {
func = [](int a, int b){};
} else {
// note: все лямбды имеют разный тип
func = [](int a, int b){};
}
}
Примечательно, что классу function
достаточно знать только тип функции и только на этапе объявления объекта.
Этот класс реализует паттерн type erasure
. Тот же самый паттерн встречается и в других классах STL.
std::optional
Класс, который хранит опциональное значение (либо шаблонный тип, либо std::nullopt_t
).
std::optional<int> convert(std::function<void(int)> &f) {
// ....
if (!fail) {
return result;
}
return {};
}
int main() {
auto val = convert(...);
if (val.has_value()) {
std::cout << "OK";
} else {
std::cout << "Fail";
}
}
std::any
Тип any
хранит в себе объект любого типа. Так одна и та же переменная типа any
может сначала хранить int
, затем float
, а затем строку.
Требуется каст для обратного преобразования.
std::any a = 42;
int v = std::any_cast<int&>(a);
a = std::string("hello");
std::string s = std::any_cast<std::string&>(a);
// a = mytype(); и так далее
Если в качестве шаблонного параметра any_cast
был передан любой тип, отличный от типа текущего хранимого объекта, будет выброшено исключение bad_any_cast
.
Если экземпляр any
разрушается деструктором, то он корректно удаляет хранимый объект.
std::variant
Шаблонный класс, который представляет собой типобезопасный union
, который помнит, какой тип он хранит. В отличие от union
, variant
позволяет хранить не только POD-типы (тривиальные типы или тривиальные классы).
std::variant<int, float, char> v;
v = 3.14f;
v = 42;
std::cout << std::get<int>(v);
Для получения значений из variant
используется функция get
. Она выбросит исключение bad_variant_access
, если попытаться взять не тот тип.
Говоря про доступ к варианту, нельзя не упомянуть visit
, принимающий функцию, которая должна уметь принимать любой тип из данного variant
.
std::variant<int, float, char> v;
v = 42;
std::visit([](auto& arg) {
using Type = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<Type, int>) {
std::cout << "int: " << arg;
} else if constexpr (std::is_same_v<Type, float>) {
std::cout << "float: " << arg;
} else if constexpr (std::is_same_v<Type, char>) {
std::cout << "char: " << arg;
}
}, v);
Заимствования:
cpp-notes/19_lambdas_type_erasure.md at master · lejabque/cpp-notes (github.com)
std::thread
Если поток не завершил работу, не вызваны методы join()
или detach()
, но его деструктор thread
уже запущен, то программа аварийно завершится вызовом std::terminate
.
После успешного вызова на потоке методов join()
или detach()
метод joinable()
будет возвращать ложь.
Вызов метода join()
на одном и том же объекте thread
из разных потоков - это undefined behavior, в том числе потому что нельзя делать join()
потоку, который возвращает joinable()
== false
. Это приводит к генерации исключения.
Если вам действительно нужно дождаться выполнения потока из разных потоков, то можно это делать более чистыми способами.
Что случится с detached
-потоками, когда программа выйдет из main?
Их исполнение будет приостановлено ОС, память освобождена (но не через деструкторы, а просто). Необходимо сделить за тем, что происходит в отсоединенных потоках, чтобы после завершения программы файлы не оставались полузаписанными и shared-память не становилась поломанной. Ресурсы наподобие блокировок на файл будут освобождены самой ОС.
std::conditional_variable
Важно знать, что conditional_variable
иногда может просыпаться и без вызова .notify_one()
, поэтому более безопасный код будет выглядеть так:
bool signaled = false;
// start background threads...
// someone will set signaled as true, then call cv.notify_one()
{
std::unique_lock<std::mutex> lock(mutex);
while (!signaled) {
cv.wait(lock);
}
signaled = false;
}
False-sharing
Существует два типа разделения кэш-линий: true sharing и false sharing.
True sharing - это когда потоки имеют доступ к одному и тому же объекту памяти, например, общей переменной или примитиву синхронизации.
False sharing - это доступ к разным данным, но по каким-то причинам оказавшимся в одной кэш-линии процессора.
И тот, и другой случай вредит производительности из-за необходимости аппаратной синхронизации кэш-памяти процессора, однако если первый случай часто неизбежен, то второй можно и нужно исключать.
В случае постоянной модификации данных в условиях false sharing, процессору в соответствии с протоколом когерентности кэша необходимо инвалидировать эту кэш-линию целиком для остальных ядер процессора.
Другой поток уже не сможет пользоваться своими данными, несмотря на то, что они уже лежат в L1 кэше его ядра. Вследствие этого между ядрами происходит синхронизация памяти. Данная операция дорого обходится, если потоки выполняют что-то в цикле - производительность может падать в разы.
На архитектуре x86 в кэш-линию может помещаться 64 байта данных, поэтому если работа происходит с массивом структур данных в многопоточке, то нужно позаботиться о следующих вещах:
- Выравнивание массива
- Наличие подкладки до 64 байт (
padding
)
Заимствования:
c++ - When should I use std::thread::detach? - Stack Overflow
Делиться не всегда полезно: оптимизируем работу с кэш-памятью / Хабр (habr.com)
std::atomic
Атомарные переменные обеспечивают атомарные взаимодействия с объектом (только я сейчас читаю/пишу в данную переменную, а остальные потоки меня ждут и т.д).
Фишка в том, что на каких-то архитектурах атомарный доступ может быть реализован отдельными инструкциями, а не просто захватом мьютекса или входом в критическую секцию.
Мьютекс - вещь тяжелая, она тянет за собой вызовы к ядру (перепланирование, усыпление потока). Некоторые компиляторы/операционные системы могут соптимизировать блокировку следующим образом: сначала ждать какое-то время в спинлоке, и только затем захватывать мьютекс - при малом ожидании к ядру можно и не обращаться.
Рассмотрим следующий не очень оптимальный код, но жить так можно:
// overkill
std::mutex m;
int a = 1;
// ...
m.lock();
a += 100;
m.unlock();
Этот же код с использованием атомарных переменных:
std::atomic<int> a(1);
// ...
a.fetch_add(100); // равносильно a += 100;
Правда, атомарные переменные по стандарту вовсе не обязаны быть lock-free
(без мьютексов и других блокировок). Проверить, что атомарная переменная является неблокирующей можно с помощью atomic<T>::is_lock_free()
.
Единственный атомарный тип с гарантированным по стандарту неблокирующим поведением - atomic_flag
.
// constructor leaves it in uninitialized state until C++20
std::atomic_flag f;
// set to false
f.clear();
// set to true and return previous value
f.test_and_set();
// return value
f.test()
В языке определены следующие специализации атомиков (некоторые опущены):
atomic_bool - std::atomic<bool>
atomic_char - std::atomic<char>
atomic_short - std::atomic<short>
atomic_int - std::atomic<int>
atomic_long - std::atomic<long>
atomic_llong - std::atomic<long long>
atomic_size_t - std::atomic<std::size_t>
В отличие от atomic_flag
у них побольше методов.
std::atomic_int a(1337);
// replace value
a.store(445);
// get value
a.load();
// replace value and return previous
a.exchange(42);
// a += 42 and return previous
a.fetch_add(42);
// a += 1 and return previous
a++;
// a += 1 and return new value
++a;
// and so on
Другие рассматривать не будем, потому что очень сложно.
На самом деле у тех операций, что мы выписали, есть дополнительный второй параметр, который называется memory_order
.
std::memory_order
memory_order
- это про порядок операций и синхронизацию памяти между потоками.
Внимание: синхронизация процесса выполнения и синхронизация памяти - это, внезапно, разные вещи!
Известно, что компилятор может переупорядочивать наш код, чтобы он работал быстрее, ровно этим же занимается процессор.
Когда мы работаем с многопоточным кодом разбрасываться порядком операций и синхронизацией уже нельзя.
Рассмотрим три типа memory_order
- relaxed
, release/acquire
и sequential consistency
.
std::memory_order_relaxed
Самый простой для понимания флаг синхронизации памяти — relaxed
. Он гарантирует только свойство атомарности операций, при этом не может участвовать в процессе синхронизации данных между потоками.
Свойства:
- Модификация переменной "появится" в другом потоке не сразу
- Поток
thread2
"увидит" значения одной и той же переменной в том же порядке, в котором происходили её модификации в потокеthread1
- Порядок модификаций разных переменных в потоке
thread1
не сохранится в потокеthread2
Можно использовать relaxed
модификатор в качестве счетчика или в качестве флага остановки.
Пример неправильного использования relaxed
:
std::string data;
std::atomic<bool> ready{ false };
void thread1() {
data = "very important bytes";
ready.store(true, std::memory_order_relaxed);
}
void thread2() {
while (!ready.load(std::memory_order_relaxed));
std::cout << "data is ready: " << data << "\n"; // potentially memory corruption is here
}
Тут нет гарантий, что поток thread2
увидит изменения data
ранее, чем изменение флага ready
, так как синхронизацию памяти флаг relaxed
не обеспечивает.
std::memory_order_seq_cst
Флаг синхронизации памяти "единая последовательность" (sequential consistency, seq_cst
) дает самые строгие свойства:
- Порядок модификаций разных атомарных переменных в потоке
thread1
сохранится в потокеthread2
- Все потоки будут видеть один и тот же порядок модификации всех атомарных переменных. Сами модификации могут происходить в разных потоках
- Все модификации памяти (не только модификации над атомиками) в потоке
thread1
, выполняющимstore
на атомарной переменной, будут видны после выполненияload
этой же переменной в потокеthread2
(свойство, как у мьютекса)
Таким образом можно представить seq_cst
операции, как барьеры памяти, в которых состояние памяти синхронизируется между всеми потоками программы.
Этот флаг синхронизации памяти в C++ используется по умолчанию, так как с ним меньше всего проблем с точки зрения корректности выполнения программы, но seq_cst
является дорогой операцией для процессоров.
std::memory_order_acquire & std::memory_order_release
Флаг синхронизации памяти acquire/release
является более тонким способом синхронизировать данные между парой потоков. Два ключевых слова: memory_order_acquire
и memory_order_release
работают только в паре над одним атомарным объектом. Рассмотрим их свойства:
- Модификация атомарной переменной с
release
будет видна видна в другом потоке, выполняющем чтение этой же атомарной переменной сacquire
- Все модификации памяти в потоке
thread1
, выполняющим запись атомарной переменной сrelease
, будут видны после выполнения чтения той же переменной сacquire
в потокеthread2
(свойство, как у мьютекса) - Процессор и компилятор не могут перенести операции записи в память раньше
release
операции в потокеthread1
, и нельзя перемещать выше операции чтения из памяти, которые были позжеacquire
операции в потокеthread2
Используя release
, мы даем инструкцию, что данные в этом потоке готовы для чтения из другого потока. Используя acquire
, мы даем инструкцию "подгрузить" все данные, которые подготовил для нас первый поток. Но если мы делаем release
и acquire
на разных атомарных переменных, то получим UB вместо синхронизации памяти.
Рассмотрим mutex на основе спинлока:
class mutex {
public:
void lock() {
bool expected = false;
while(!_locked.compare_exchange_weak(expected, true, std::memory_order_acquire)) {
expected = false;
}
}
void unlock() {
_locked.store(false, std::memory_order_release);
}
private:
std::atomic<bool> _locked;
};
Обратите внимание, что мьютекс не только обеспечивает эксклюзивный доступ к блоку кода, который он защищает. Он также делает доступным те изменения памяти, которые были сделаны до вызова unlock()
в коде, который будет работать после вызова lock()
. Это важное свойство. Иногда может сложиться ошибочное мнение, что мьютекс в конкретном месте не нужен.
Заимствования:
std::atomic. Модель памяти C++ в примерах / Хабр (habr.com)
Квалификатор volatile
Квалификатор volatile
говорит компилятору, что оптимизировать данную переменную запрещено, и что компилятор никогда не сможет предвидеть или вычислить ее значение заранее.
volatile int flag = 42;
Когда это вообще нужно? Рассмотрим следующий пример:
bool cancel = false;
/* ... */
while (!cancel) {
/* ... */
}
Если в теле цикла переменная cancel
не меняется, то компилятор может соптимизировать ее проверку, и в цикл мы не войдем никогда.
Таким и должно быть наблюдаемое поведение, если мы не пишем сложной логики, например, если переменная cancel
не шарится с другими потоками или процессами, которые могут ее перезаписывать. Использование volatile
нужно для предупреждения компилятора, что магические силы могут изменить значение переменной в любой момент и нельзя опираться на то, что в нее было записано в compile-time.
Другой пример:
unsigned char* pControl = 0xff24;
void f() {
*pControl = 1;
*pControl = 0;
*pControl = 0;
}
Компилятор может опустить первые две операции присваивания, оставив только последнюю. Когда это может быть вредно? При разработке программ, работающих с Memory mapped IO
, то есть взаимодействующих с какими-то железками через оперативную память, или при разработке драйверов, где важно каждое переданное значение.
Использование volatile
запрещает перегруппировку инструкций доступа и их оптимизацию, но ни в коем случае не гарантирует атомарности. Чтение volatile
переменной при одновременной записи в нее из другого потока или одновременная запись из разных потоков без синхронизации - это data race.
Также использование volatile
запрещает использование переменной из регистра - каждое чтение будет связано с загрузкой из оперативной памяти. Это свойство кажется уже очевидным, но тем не менее стоит подчеркнуть, что такое поведение можно использовать для каких-нибудь бенчмарков.
Разумеется, нельзя использовать волатильную переменную со снятым позднее через const_cast
квалификатором volatile
- это undefined behavior.
Связь с квалификатором const
Как и const
, volatile
- это cv-квалификатор, разумеется, поэтому нужно строго понимать, куда писать это слово в объявлении типа.
Пример выше исправляется следующим образом, потому что в функции f
мы делаем присвоения в unsigned char&:
volatile unsigned char* pControl = 0xff24;
Но если нужна именно volatile
-переменная, а не данные по указателю, то это должно писаться следующим образом:
unsigned char* volatile pControl = 0xff24;
Заимствования:
volatile type qualifier - cppreference.com
Алёна C++: Ключевое слово volatile (alenacpp.blogspot.com)
Примеры использования promise и future
// Create a promise
std::promise<int> promise;
// And get its future
std::future<int> future = promise.get_future();
// You can also get a shared future this way, by the way! (Choose one please)
std::shared_future<int> shared_future = promise.get_future();
// Now suppose we passed promise to a separate thread.
// And in the main thread we call...
int val = future.get(); // This will block!
// Until, that is, we set the future's value via the promise
promise.set_value(10); // In the separate thread
// So now in the main thread, if we try to access val...
std::cout << val << std::endl;
// Output: 10
Из заметки про атомики здесь заметна семантика release
/acquire
. Так и будет - все изменения, сделанные потоком, записавшим в promise
значение, будут видны потоку, который прочитал future
.
#include <iostream>
#include <thread>
#include <future>
void initiazer(std::promise<int> *promObj) {
std::cout << "Inside Thread" << std::endl;
promObj->set_value(35);
}
int main() {
std::promise<int> promiseObj;
std::future<int> futureObj = promiseObj.get_future();
std::thread th(initiazer, &promiseObj);
std::cout << futureObj.get() << std::endl;
th.join();
return 0;
}
std::async
Наипростейший способ использовать async
- это просто передать callback-функцию как аргумент и дать системе сделать все за тебя.
auto future = std::async(some_function, arg_1, arg_2);
Стоит отметить, что async
поддерживает параллелизм, но конструктор по умолчанию может не запускать переданные функции в отдельном потоке. Для того, чтобы функция точно выполнилась в отдельном потоке, необходимо явно сказать async
об этом.
Also, since Linux threads run sequentially by default, it's especially important to force the functions to run in separate threads. We'll see how to do that later.
Политики запуска std::async
Есть три способа запустить асинхронную задачу:
std::launch::async
- гарантирует запуск в отдельном потокеstd::launch::deferred
- функция будет вызвана только при вызовеget()
std::launch::async | std::launch::deferred
- поведение по умолчанию, отдать на откуп системе.
Запуск задачи будет выглядеть так:
auto future = std::async(std::launch::async, some_function, arg_1, arg_2);
Еще примеры:
// Pass in function pointer
auto future = std::async(std::launch::async, some_function, arg_1, arg_2);
// Pass in function reference
auto future = std::async(std::launch::async, &some_function, arg_1, arg_2);
// Pass in function object
struct SomeFunctionObject {
void operator() (int arg_1) {}
};
auto future = std::async(std::launch::async, SomeFunctionObject(), arg_1);
// Lambda function
auto future = std::async(std::launch::async, [](){});
Заимствования:
Корутины
Корутины - это потоки исполнения кода, которые организуются поверх аппаратных (системных) потоков и работают на более высоком уровне - несколько корутин могут по очереди выполнять свой код на одном системном потоке (в зависимости от реализации, корутины могут быть не привязаны к конкретному системному потоку, а например выполнять свой код на пуле потоков).
В отличие от системных потоков, которые переключаются системой в произвольные моменты времени (вытесняющая многозадачность), корутины переключаются вручную в местах, указанных программистом (кооперативная многозадачность).
Простыми словами: корутина может остановиться и передать управление другому потоку (другой корутине), а потом вернуться к текущей инструкции и продолжить выполнение до либо очередной паузы и передачи потока выполнения, либо завершения работы.
В C++20 есть три механизма для работы с корутинами:
co_await
- ожидание асинхронного результата
co_yield
- приостанавливает работу корутины и возвращает какое-то значение
co_return
- возвращает значение и завершает работу корутины
Функция является корутиной, если в ней есть хотя бы одна из этих трех команд.
У корутин в С++ есть ограничения:
- Обязана иметь тип возрата (?)
- Не может быть функцией с variadic templates
- Не может быть функцией с оператором
return
- Не может быть функцией с автоматичским выведением типа возвращаемого значения (
auto
) - Не может быть
constexpr
-функцией - Не может быть конструктором
- Не может быть деструктором
- Не может быть функцией
main
Есть два типа корутин.
Stackless-корутины
Таковыми они являются в С++20, и это значит, что корутина при запуске создает на куче пространство, которое будет являться ее памятью для возобновления состояния. Туда она помещает аргументы функции, которые ей передали. Данные, которые ей для возобновления работы не сильно нужны, она складывает в стек вызывающей стороны.
Аргументы по значению она скопирует/помувает, а ссылки останутся ссылками (отсюда следует, что когда корутина вернулась к выполнению кода, у нее может остаться невалидная ссылка, если объект уже уничтожили).
Всю магию переключений между stackless-корутинами компилятор вправе реализовать через конечный автомат и скорее всего так и сделает (в интернете есть пример с огромным оператором switch
).
Stackfull-корутины
Такие корутины имеют свой собственный стек и менеджатся хорошо только на уровне ОС. Иначе можно делать магию через свап в фреймах оперативной памяти с помощью ассемблерного кода - что-то об этом упоминал Иван Сорокин на лекциях по C++ Advanced в университете ИТМО.
Stackfull-корутины появились в WinApi уже давно и называются Fiber.
Примеры использования корутины
X coroutine() {
co_yield "Hello ";
co_yield "world";
co_return "!";
}
int main() {
auto x = coroutine();
std::cout << x.next();
std::cout << x.next();
std::cout << x.next();
std::cout << std::endl;
}
Кажется, что тип X нужно писать самому.
X foo() {
co_return 42;
}
X bar() {
const auto result = foo();
const int i = co_await result;
co_return i + 23;
}
Пример корутины-генератора для факториала:
X factorial() {
int a = 1;
int b = 1;
for (;;) {
b *= a;
a += 1;
co_yield b;
}
}
Заимствования:
c++ - Сопрограммы (корутины, coroutine) - что это? - Stack Overflow на русском