Объект

Объект - это участок памяти, у которого есть

  1. Размер
  2. Выравнивание
  3. Тип размещения
  4. Время жизни
  5. Тип
  6. Значение (может быть не определено)
  7. Имя (необязательно)

Объектами не являются

  • Значения
  • Ссылки
  • Функции
  • Перечисление (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:

  1. В пределах любой единицы трансляции шаблон, тип данных, функция или объект не могут иметь более одного определения, но могут иметь неограниченное число объявлений.
  2. В пределах программы (совокупности всех единиц трансляции) объект или не-inline функция не могут иметь более одного определения; если объект или функция используются, у каждого из них должно быть строго по единственному определению.
  3. Типы, шаблоны и 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) = &sum; // эквивалентно

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 — это:

  1. массив из пяти
  2. указателей
  3. на функцию, принимающую char
  4. и возвращающую void*

Пример от Артема К

int (* (** (* (* x)[5])(void))[10])();

x - это указатель на массив размера 5 из указателей на функции, принимающие void (то есть не принимающие аргументов - это альтернативный синтаксис) и отдающие указатель на указатель на массив размера 10 из указателей на функции без аргументов, возвращающие int

До прихода C++11

  1. lvalue - это то, что может стоять слева от оператора присваивания.
  2. rvalue - это "временные объекты", им нельзя что-то присваивать. Кажется, что у них нельзя было взять адрес.

С приходом C++11 появилась move-семантика, из-за чего схема стала древовидной и усложнилась в понимании.

Сейчас

Введем пару понятий:

  • Наличие идентичности (identity) – наличие какого-то параметра, по которому можно понять, ссылаются ли два выражения на одну и ту же сущность (например, адрес в памяти)
  • Возможность перемещения (можно ли объект переместить, помувать)

Выражения сейчас делятся на два больших типа:

  • glvalue - обладают идентичностью
  • rvalue - могут быть перемещены

Эти категории распадаются в сумме на три (одна у них общая).

  1. glvalue

    • lvalue - обладают идентичностью, но не могут быть перемещены
    • xvalue - обладают идентичностью, могут быть перемещены
  2. 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();
}

Здесь есть две проблемы:

  1. Классы left и right оба имеют метод void f(), поэтому мы получим ошибку компиляции (но ведь для нас этот метод все равно один, в каком-то смысле!)
  2. Класс 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 способов приведения типов

  1. C-style cast
  2. static_cast<T>
  3. dynamic_cast<T>
  4. reinterpret_cast<T>
  5. 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)

Синтаксис:

  1. ( pack_name op ... )
  2. ( ... op pack_name )
  3. ( pack_name op ... op init )
  4. (init op ... op pack )

где pack_name - имя parameter pack, op - оператор, init - начальное значение.

Во что эти конструкции разворачиваются:

  1. ( E op ... ) -> ( E1 op (... op ( En-1 op En )))
  2. ( ... op E) -> ((( E1 op E2 ) op ...) op En )
  3. ( E op ... op I ) -> ( E1 op (... op ( En op I )))
  4. ( 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);
  1. Для сопоставления f(1, 2) с конкретной функцией компилятор отправит все функции с названием f в overload resolution.
  2. Далее из списка исчезают кандидаты, у которых количество параметров не может совпасть с теми, что представлены в вызове.
  3. Потом отсекаются функции, типы параметров которых отличаются от переданных аргументов и для которых нет неявного преобразования.
  4. После этого идут несложные, но многословные правила поиска лучшей перегрузки, и побеждает 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-объект должен корректно уничтожить принадлежащий ему объект.

Существует три типа гарантии безопасности исключений:

  1. Базовая гарантия - при возникновении любого исключения в некотором методе, состояние программы должно оставаться согласованным. Это означает, не только отсутствие утечек ресурсов, но и сохранение инвариантов класса.
  2. Строгая гарантия - если при выполнении операции возникает исключение, то это не должно оказать какого-либо влияния на состояние приложения. Другими словами, строгая гарантия исключений обеспечивает транзакционность операций.
  3. Гарантия отсутствия исключений - ни при каких обстоятельствах функция не будет генерировать исключения.

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 - помещать совместно вызываемые блоки кода в общую страницу памяти

И так далее, лучше глянуть официальную документацию.

После запуска программы со сбором статистики необходимо скомпилировать программу еще раз уже с учетом этой статистики.

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 байта данных, поэтому если работа происходит с массивом структур данных в многопоточке, то нужно позаботиться о следующих вещах:

  1. Выравнивание массива
  2. Наличие подкладки до 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, [](){});

Заимствования:

coding-notes/07 C++ - Threading and Concurrency.md at master · methylDragon/coding-notes (github.com)

Корутины

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

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

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

В 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 на русском