Грокаем C++
8.95K subscribers
34 photos
1 video
3 files
472 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
std::invoke
#опытным

Если вы хотите универсально работать с любыми коллбэками в вашем коде, то вам просто необходимо знать эту функцию и уметь с ней работать. Это единственный способ в С++ вызвать любую сущность, походящую на функцию.

Давайте посмотрим на пример:

template <typename Callback, typename... Args>
void process_and_call(Callback&& callback, Args&&... args) {
// some processing
std::forward<Callback>(callback)(std::forward<Args>(args)...);
}


Все просто: передаем шаблонный коллбэк и его аргументы и используем perfect forwarding для вызова коллбэка.

Есть ли проблема в этом коде? Задумайтесь на секунду.

И проблема есть!

Что если мы попробуем передать в process_and_call указатель на нестатический метод класса? Метод класса - это такая же функция, просто она принимает неявный параметр this. В С++ есть специальный синтаксис для вызова методов классов:

class Data {
public:
void memberFunction(int value) {
std::cout << "Data::memberFunction called with value: " << value << "\n";
}
};

Data data;

// Создаем указатель на метод класса
auto methodPtr = &Data::memberFunction;

// Вызываем метод через указатель на объекте
(data.*methodPtr)(42);


Согласитесь, что этот синтаксис отличается от std::forward<Callback>(callback)(std::forward<Args>(args)...);.

Поэтому такая реализация process_and_call несовершенна. Как можно это исправить?

Использовать std::invoke. Это функция буквально создана, чтобы вызывать все, что только можно вызвать. Она конечно же написана на вариабельных шаблонах, чтобы вы могли передать туда все, что душе угодно:

template< class F, class... Args >  
std::invoke_result_t<F, Args...> invoke( F&& f, Args&&... args );


Давайте посмотрим на корректную реализацию process_and_call с использованием std::invoke:
template <typename Callback, typename... Args>
void process_and_call(Callback&& callback, Args&&... args) {
// some processing
std::invoke(std::forward<Callback>(callback), std::forward<Args>(args)...);
}


struct S {
void foo(int i) {}
};

process_and_call(&S::foo, S{}, 42);

Прекрасная функция, которая может сильно упростить работу с callback'ами.

Be universal. Stay cool.

#cppcore #template
120👍12🔥8🤔1🤯1
​​Все вызываемые сущности в С++. Ч1
#новичкам

В С++ полно вещей, которые можно вызывать, как функции. Но, как мы убедились в прошлый раз, не только функции в плюсах можно "вызывать". Хотя указатели на поля класса - это единственное такое исключение, давайте перечислим все вызываемые сущности в С++. Поехали!

👉🏿 Функции. Старые-добрые и всем знакомые функции. Очевидный кандидат:

void free_function(int a) {
std::cout << "Free function: " << a << std::endl;
}

free_function(42);



👉🏿 Указатели на функцию
Если вы хотите куда-то передать функцию, как коллбэк, то скорее всего стриггерите неявное приведение имени функции к указателю на функцию. Вот так например:

void free_function(int a) {
std::cout << "Free function: " << a << std::endl;
}

std::invoke(free_function, 42);


Внутрь invoke free_function передастся как именно как указатель.

Указатель на функцию можно и явно определить и вызвать с его помощью соответствующей функции:

auto fun_ptr = &free_function;

fun_ptr(42);
(*fun_ptr)(42);

Когда мы просто передаем имя функции в call_callback, это имя приводится к типу указателя на функцию:

void free_function(int a) {
std::println("Free function: {}", a);
}

call_callback(free_function, 42);


👉🏿 Ссылки на функции

Да, и такие тоже есть:

auto& fun_ref = free_function;
fun_ptr(42);
(*fun_ptr)(42); // интересно, что такой синтаксис разрешен


Если передать в функцию в качестве параметра по ссылке, то тогда тоже получите ссылку на функцию:

void check_name(auto& obj) {
// by passing free_function to check_name, obj become function reference
}
check_name(free_function);



👉🏿 Статические методы классов

struct Handler {
static void static_method(int a) {
std::cout << "Static method: " << a << std::endl;
}
};

Handler::static_method(42);

Также мы можем явно определить указатель или ссылку на статический метод класса, так как это обычная функция:

auto static_method_ptr = &Handler::static_method;
std::invoke(static_method_ptr, 42);


👉🏿 Функциональные объекты
В обывательском понимании функторы - это объекты классов с определенным оператором(). Однако не все так просто. На самом деле с точки стандарта функциональный объект - это одно из 3-х: указатель на функцию, объект класса с определенным оператором() и объект класса с определенным оператором приведения к указателю на функцию. Их всех объединяет одинаковый синтаксис вызова, поэтому их засунули в одну категорию. Указатели на функции мы разобрали, рассмотрим 2 оставшихся.

👉🏿👉🏿 Объект класса с определенным вызова operator(). Это такие стандартные олдскульные(до С++11) функторы, которые использовались, например, для передачи в стандартные алгоритмы. Название, в общем, полностью описывает реализацию. Единственное, что хочется отметить - необходимость таких функторов заключена в их способности хранить стейт, иначе можно было использовать обычные функции.

class SequenceGenerator {
int current;
int step;
public:
SequenceGenerator(int start = 0, int step_size = 1)
: current(start), step(step_size) {}

// Оператор вызова без аргументов - возвращает следующее число
int operator()() {
int val = current;
current += step;
return val;
}
};
SequenceGenerator gen;
std::vector<int> numbers(5);
std::generate(numbers.begin(), numbers.end(), gen); // заполняем вектор с помощью функтора



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

Have all the tools. Stay cool.

#cppcore
27👍12🔥8😱1
Все вызываемые сущности в С++. Ч2
#опытным

Продолжаем перечислять все сущности, которые можно "вызывать" в С++.

И начинаем с продолжения перечисления всего, что относится к функторам:

👉🏿👉🏿 Лямбды

Там, где говорят про функторы, всегда водятся лямбды. Лямбды - это такие карманные функциональные объекты, которые определяются на ходу. У них также определен соответствующий оператор вызова operator(). Вот как может выглядеть код из предыдущего пункта с помощью лямбды:

int start = 5;
int step = 2;

auto gen = [current = start, step]() mutable {
return std::exchange(current, current + step);
};

std::vector<int> numbers(5);
std::generate(numbers.begin(), numbers.end(), gen);


Накидайте огней, если хотите узнать, как 3 строчки из лямбды в предыдущем посте превратились в одну с помощью std::exchange)

👉🏿👉🏿 Объект класса с определенным оператором приведения к указателю на функцию

Солидная часть подписчиков даже не слышала о такой сущности. Но работает это тривиально: при "вызове" такого объекта он кастится к указателю на функцию и уже с его помощью реально делается вызов:

class FunctionObjectCast {
public:
using fun_ptr = int ()(int);
// Оператор приведения к указателю на функцию
operator fun_ptr() {
return &staticMethod;
}

// Статический метод, который будет вызываться через указатель
static int staticMethod(int x) {
return x * 2;
}
};

FunctionObjectCast obj;
obj(42); // cast here


Но даже, если вы ни разу не слышали про функторы такого вида, то с высокой долей вероятности все равно неявно пользовались им. Это конечно же лямбды без захвата.

👉🏿👉🏿 Лямбды без захвата. Именно с помощью оператора приведения к указателю на функцию лямбды без захвата можно маскировать под указатели на функции. Это позволяет использовать их в качестве коллбэков к сишным функциям или колдовать такую магию.

std::vector<int> nums = {5, 3, 9, 1};
std::qsort(nums.data(), nums.size(), sizeof(int),
[](const void* a, const void* b) -> int {
return ((int)a - (int)b);});


std::qsort принимает последним параметром именно указатель на функцию, поэтому конкретно в этом кейсе и срабатывает приведение к этому указателю.

В случае привычного вызова самой лямбды используется operator().

👉🏿 Нестатический метод

struct Handler {
void foo(int a) {
std::cout << "Non-static method: " << a << std::endl;
}
};

Handler{}.foo(42);


👉🏿 Указатель на нестатический метод. Если вы хотите передать метод класса в качестве коллбэка, то вам необходимо это делать через указатель на метод. Метод класса - это такая же функция, только первым параметром она неявно принимает this. Если делать явный вызов метода через указатель на него, то эта неявная передача становится явной:

struct Handler {
void foo(int a) {
std::cout << "Non-static method: " << a << std::endl;
}
};

auto non_static_method_ptr = &Handler::foo;
std::invoke(non_static_method_ptr, Handler{}, 42);


Чтобы вызвать метод класса по его указателю через std::invoke нужно вторым параметром передать объект класса, на котором мы хотим вызвать метод. Далее идут аргументы в порядке, определенном в методе.

Замечу, что использования оператора взятия адреса(&) перед именем метода здесь обязательно. Имена нестатических методов не имеют желания неявно приводится к указателям, как имена функций. Вот так нельзя:
std::invoke(Handler::foo, Handler{}, 42);

Это синтаксис для использования статических методов и компилятор будет ругаться.

👉🏿 Указатель на поле класса. Недавно обсуждали, что с помощью std::invoke можно "вызывать" поля класса. Неочевидно, но бывает полезно.

struct Payment {
double amount;
std::string category;
}

Payment p{100500, "confetki"};
std::cout << std::invoke(&Payment:amount, p) << std::endl;
// OUTPUT:
// 100500


Фух. Вроде все. Пишите, если что забыл.

Have all the tools. Stay cool.

#cppcore
🔥40👍84❤‍🔥2
std::exchange
#опытным

В прошлом посте мы пасхалкой использовали std::exchange, давайте же разберем эту функцию по-подробнее.

По названию в целом понятно, что она делает - что-то обменивает. Но не как std::swap, меняет значения местами. Все немного хитрее.

Она заменяет старое значение новым и возвращает старое значение. Вот примерная реализация:

template<class T, class U = T>
constexpr // Since C++20
T exchange(T& obj, U&& new_value) {
T old_value = std::move(obj);
obj = std::forward<U>(new_value);
return old_value;
}


Как говорил Константин Владимиров: "единожды научившись использовать std::exchange, вы дальше будете делать std::exchange всю оставшуюся жизнь".

Понятное дело, что это не какой-нибудь std::move, который реально постоянно приходится использовать. Однако важен сам паттерн. Если посмотреть в кодовые базы, то будет куча мест, где можно использовать эту функцию. Приведу пару примеров работы std::exchange, чтобы вы поняли смысл.

Начнем со знакомого:

auto gen = [current = start, step]() mutable {
return std::exchange(current, current + step);
};

std::vector<int> numbers(5);
std::generate(numbers.begin(), numbers.end(), gen);


Мы определяем мутабельную лямбду(кстати это один из удачных примеров использования таких лямбд), которая может изменять свои захваченные по значению переменные current и step. Дальше на каждом вызове мы должны вернуть текущее значение current, но перед этим как-то его увеличить. Можно использовать прокси-переменную:

int val = current;
current += step;
return val;


Но зачем, если у нас уже есть готовая и протестированная функция std::exchange? Это прекрасный способ немного уменьшить код и увеличить его читаемость.

Другой пример - генерация чисел Фибоначчи:

auto gen = [current = 0, next = 1]() mutable {
return current = std::exchange(next, current + next);
};

std::vector<int> fib(10);
std::generate(fib.begin(), fib.end(), gen);
for (int i = 0; i < fib.size(); ++i) {
std::cout << fib[i] << " ";
}
// OUTPUT:
// 1 1 2 3 5 8 13 21 34 55


Хорошенько вдумайтесь и осознайте, что здесь происходит. Ну ведь красиво, правда?)

Если у вас есть вектор коллбэков, который вы постепенно копите и в какой-то момент обрабатываете все разом. Коллбэки безопасно выполнять вне лока. Но как их нормально обработать разом, чтобы 100500 раз не дергать мьютексы? На такой случай есть прикольная техника. Нужно под локом получить копию, а обрабатывать ее вне лока.

class Dispatcher {
// ...

// All events are dispatched when we call process
void process() {
const auto tmp = [&] {
std::lock_guard lock{mutex_};
return std::exchange(callbacks_, {});
}();
for (const auto& callback : tmp) {
std::invoke(callback);
}
}
};


И все. Копию(на самом деле перемещенную копию) получаем под локом, а обрабатываем все без замков. Круто!

Ну и конечно, без принципа exchange не обойтись в lock-free программировании. Та же std::atomic_exchange работает ровно по той же логике.

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

Be elegant. Stay cool.

#cppcore #cpp14 #multitasking #fun
224🔥12👍9😱2
Лямбды без захвата
#опытным

Сегодня немного проясню ситуацию с лямбдами без захвата.

Повторю предыдущий пост, лямбды без захвата - это объекты с определенным оператором приведения к указателю на функцию. Так как лямбды ничего не захватывают, то их замыкания не содержат полей, поэтому, в целом, для их вызова необязателен объект. Но как именно это реализовано в лямбдах? Сейчас и посмотрим.

Давайте взглянем на простую лямбду:

auto fun = [](int i) { return i*2;};


У нее очевидно нет захвата, поэтому она как раз наш абонент. С помощью cppinsights можно посмотреть под капот компилятора и то, во что лямбды и другие сущности превращаются. Примерно вот такую сущность сгенерируют компилятор для этой лямбды:

class __lambda_11_15
{
public:
inline int operator()(int i) const
{
return i * 2;
}

using retType_11_15 = int (*)(int);
inline constexpr operator retType_11_15 () const noexcept
{
return __invoke;
}

private:
static inline /*constexpr */ int __invoke(int i)
{
return __lambda_11_15{}.operator()(i);
}
};


И тут много интересного!

Например, у лямбды без захвата все же генерируется оператор вызова operator().

В добавок к этому определяется оператор приведения к указателю на функцию operator retType_11_15 (), который фактически приводит приватный статический метод класса к указателю. А для переиспользования кода, статический метод на лету конструирует объект и вызывает у него operator().

То есть вот примерно как это работает:

int apply_function(int (*func)(int), int value) {
return func(value); // Вызываем переданную функцию
}

int main() {
auto fun = [](int i) { return i2;};
fun(42);
apply_function(fun, 42);
return 0;
}


Здесь у нас 2 вида использования лямбды: через объект замыкания и через коллбэк в apply_function. Посмотрим, что будет вызываться в каждом конкретном случае:

int main()
{
__lambda_11_15 fun = __lambda_11_15{};
fun.operator()(42);
apply_function(fun.operator __lambda_11_15::retType_11_15(), 42);
return 0;
}


В случае вызова через объект замыкания триггерится operator(), а при передаче в другую функцию - оператора приведения к указателю на функцию.

Зачем два способа вызова? Почему нельзя обойтись просто приведением к указателю на функцию?

Вызывать лямбду через указатель на функцию - это лишить себя основной оптимизации компилятора - инлайнинга. Если передавать лямбду, как полноценный тип замыкания, то компилятор будет знать, как встроить код его operator() внутрь callee, потому что все типы определены на этапе компиляции. А по указателю на функцию можно передать все, что угодно. В простых случаях, как в apply_function, может и все хорошо будет. Но в более сложных - вы лишитесь оптимизации.

Но благо указатели на функции используются преимущественно в сишном апи и по-другому просто не получится. В С++ есть шаблоны и объекты и надобность в использовании указателей на функции практически отпадает.

Надеюсь, теперь вы чуть больше о лямбдах знаете)

Know more. Stay cool.

#cppcore #cpp11
🔥18👍105❤‍🔥1
std::mem_fn
#опытным

Допустим, у вас есть вектор тасок и вам нужно выполнить каждую из них и поместить результаты выполнения в другой вектор:

struct Task {
int execute() {
return 42;
}
};

std::vector<Task> tasks(10);
std::vector<int> results;

Примерно так это может выглядеть в суперупрощенном виде.

Эту задачу легко решить с помощью цикла:

results.reserve(tasks.size());
for (auto& task: tasks) {
result.emplace_back(task.execute());
}


И дело в шляпе.

Однако cppcoreguidelines нам говорят:
Use standard algorithms where appropriate, instead of writing some own implementation.

Может ли мы здесь использовать стандартные алгоритмы? Да, конечно можем. С рэнджами это очень легко:

std::vector results = tasks | 
std::views::transform(&Task::execute) |
std::ranges::to<std::vector>();


Мы уже говорили в этом посте, что алгоритмы диапазонов обязаны использовать std::invoke под капотом, поэтому std::views::transform легко переварит указатель на метод.

Но что, если вы живете в эру до С++20? У вас есть стандартный std::transform, однако он уже не такой умный и не умеет принимать указатели на методы.

std::transform(tasks.begin(), tasks.end(), 
std::back_inserter(results), &Task::execute); // Not working!


Какие варианты у нас есть в такой ситуации?

❗️ std::function

std::transform(tasks.begin(), tasks.end(), 
std::back_inserter(results), std::function<int(Task&)>(&Task::execute));


Works, but at what costs?

std::function в данной ситуации - это бить по воробьям ракетами. std::function обычно работает сильно медленнее, чем прямой вызов. Поэтому давайте посмотрим на других кандидатов.

👍 Использовать лямбду

std::transform(tasks.begin(), tasks.end(), 
std::back_inserter(results), [](auto& input) { return input.execute(); });


Это работает, но приходится городить огород вокруг execute. Лямбды всегда вносят некоторый "шум" в код из-за чего его сложнее воспринимать. Поэтому это не самый идеальный вариант. Есть что-то получше?


std::mem_fn. Спонсор сегодняшней передачи. std::mem_fn принимает указатель на метод и возвращает тонкую обертку над ним, которая условно позволяет вызывать методы класса не так (x.*&Item::Foo)(), а вот так: (&Item::Foo)(x). То есть позволяет унифицировать синтаксис вызова указателя на метод класса с синтаксисом вызова обычной функции. С помощью std::mem_fn наш код трансформации выглядит вот так:

std::transform(tasks.begin(), tasks.end(),
std::back_inserter(results), std::mem_fn(&Task::execute));


Минимум кода вокруг execute без потери производительности. Кайф!

Отличная функция, которая позволит вашему коду быть выразительным и быстрым.

Express yourself. Stay cool.

#cppcore #cpp20 #STL
25👍14🔥10❤‍🔥2
​​Разница между std::stoi+std::to_string и std::from_chars+std::to_chars
#опытным

В C++ есть два основных подхода к конвертации чисел в строки и обратно:

Старомодный  — std::stoi, std::to_string (C++11)

Модномолодежный — std::from_chars, std::to_chars (C++17)

В чем принципиальная разница между ними и когда какой подход использовать? Сегодня мы широкими мазками ответим на эти вопросы.

Особенности старомодного подхода:

👉🏿 Основное - это исключения. Все нештатные ситуации обрабатываются с их помощью, что ведет к неким накладным расходам.

👉🏿 Работа в высокоуровневом ООП стиле. Используются классы и возвращаются классы, без всяких сырых буферов.

👉🏿 Нет контроля над парсингом. Нет возможности задать формат, основание системы счисления или точность. Но для большинства кейсов это и не нужно.

👉🏿 Поддержка локалей. Грубо говоря, это механизм для учёта региональных особенностей представления данных. То есть std::stoi, std::to_string реализованы с учетом возможности спецификации локалей и соотвественно изменения результатов конвертации. С локалями возможна такая штука:
// В США (локаль "en_US"):
std::to_string(3.14); // "3.14" (точка как разделитель)

// В Германии (локаль "de_DE"):
std::to_string(3.14); // Может вернуть "3,14" (запятая)!
Естественно, что поддержка такой фичи чего-то да стоит.

Особенности модномолодежного подхода:

👉🏿 Функции std::from_chars, std::to_chars спроектированы быть настолько легкими и быстрыми, насколько это возможно на таком уровне абстракции.

👉🏿 Отсутствие намеренных динамических аллокаций. Только вы решаете, где расположена память по данные.

👉🏿 Отсутствие исключений. Функции возвращает объект ошибки, который явно нужно проверять руками.

👉🏿 Не проверяет локали.

👉🏿 Поддерживают частичный парсинг.

👉🏿 Поддержка явной гарантии round-trip. Если вы запишите в строку число с помощью std::to_chars и прочитаете его с помощью std::from_chars, то вы всегда получите изначальный результат. Главное, чтобы обе функции были вызваны с использованием одинаковой реализации стандартной библиотеки. Но у std::stoi, std::to_string и этого нет.


Если вы работаете в высоконагруженном или ограниченном по производительности окружении, то ваш выбор явно std::from_chars, std::to_chars. Обычно в коде таких приложений отказываются от использования исключений, поэтому проблем с код-стайлом не будет.

Возможность поэтапного парсинга также не оставляет выбора - используйте std::from_chars.

Если вы не паритесь за производительность, любите объекты, вам не нужен частичный парсинг или каждый раз при виде слова exception у вас не начинает идти пена изо рта, то придерживайтесь старого подхода.

Choose the right tool. Stay cool.

#cppcore #cpp11 #cpp17
12🔥2011👍10😱1
Зачем в pimpl определять специальные методы в cpp?
#опытным

В прошлом посте вы могли заметить, что все специальные методы класса Serializer только объявляются в хэдере, а реализуются в файле исходников. Зачем так делать?

class Serializer {
public:
Serializer();
~Serializer();

// only move-semantic
Serializer(const Serializer&) = delete;
Serializer& operator=(const Serializer&) = delete;
Serializer(Serializer&&) noexcept;
Serializer& operator=(Serializer&&) noexcept;

//...
private:
std::unique_ptr<struct Impl> pimpl_;
};


Мы ведь привыкли либо вообще не определять никакие специальные методы по правилу нуля, либо просто помечать их default и все. Почему в этот раз так нельзя сделать?

Все дело в нашем особенном указателе pimpl_. Это умный указатель на forward-объявленную структуру. Это значит, что мы не знаем примерно ничего об этой структуре, кроме ее имени. И в этом загвоздка.

Когда мы явно помечаем специальные методы класса как default, мы явно просим компилятор за нас сгенерировать определение этих методов.

class Serializer {
public:
Serializer();
~Serializer() = default; // Here

// only move-semantic
Serializer(const Serializer&) = delete;
Serializer& operator=(const Serializer&) = delete;
Serializer(Serializer&&) noexcept = default; // Here
Serializer& operator=(Serializer&&) noexcept = default; // Here

//...
private:
std::unique_ptr<struct Impl> pimpl_;
};


То есть мы запрашиваем определение метода в теле класса. На основе чего компилятор будет генерировать их? На основе того, что есть в теле класса.

Но вот беда, в теле класса нет никакой информации о том, что из себя представляет класс Impl. Возьмем тот же деструтор. У std::unique_ptr тип делитера зашит в шаблонный параметр. По дефолту это default_deleter, который вызывает просто delete ptr . А у delete expression есть пометка, что при удалении указателя на неполный тип программа становится ill-formed. Поэтому компиляторы на случай, если вы захотите сгенерировать деструктор std::unique_ptr с неполным типом, ставят такой ассерт времени компиляции: static_assert(sizeof(_Tp)>0, "can't delete pointer to incomplete type");. А компиляция последнего примера крашнется с соотвествующей ошибкой.

С перемещающими операциями похожая история. Когда мы используем операции std::unique_ptr мы инстанцируем этот шаблон с конкретными шаблонными параметрами. Значит должен инстанцироваться и default_deleter, который не может работать с неполными типами, как уже было сказано выше.

Так что определяйте специальные методы в реализации при использовании pimpl и будет вам счастье.

Don't give impossible tasks. Stay cool.

#cppcore #design
🔥188👍7
​​Ответ на квиз
#новичкам

В этом коде:

class Bar {
public:
Bar() {
throw std::runtime_error("Error");
}
};

int main() {
Bar* bar = nullptr;

try {
bar = new Bar();
} catch(...) {
std::cout << "Houston, we have a problem" << std::endl;
}
}

Не будет утечки памяти. Стандарт нам это гарантирует при использовании new expression.

Дело вот в чем. В этом посте мы поговорили о том, что есть 3 вида new:

👉🏿 operator new, который выделяет память.

👉🏿 placement new, который вызывает конструктор на заданной памяти.

👉🏿 new expression, который в начале выделяет память через operator new, а потом конструирует объект на этой памяти через placement new. Это именно то, что используется в коде выше.

Так вот, new expression заботится о своих пользователях и оборачивает в try-catch вызов конструктора объекта. В catch оно освобождает память и пробрасывает исключение наружу:

Bar* bar = new Bar();
// инструкция выше эквивалентнас следующему коду:
Bar* bar;
void* tmp = operator new(sizeof(Bar));
try {
new(tmp) Bar(); // Placement new
bar = (Bar*)tmp; // The pointer is assigned only if the ctor succeeds
}
catch (...) {
operator delete(tmp); // Deallocate the memory
throw; // Re-throw the exception
}


И получается, что и исключение есть, и память освобождена. Таким образом new обеспечивает базовую гарантию исключений, чтобы программа осталась в согласованном состоянии и отсутствовали утечки памяти.

Вообще, это даже интересная техника. Ловить исключение, заметать следы преступления и пробросить исключение дальше. Может помочь избежать утечек памяти в конструкторах ваших классов, которые сами внутри выделяют память.

Спасибо, @PyXiion за предоставленную информацию)

Don't let your memory leak. Stay cool.

#cppcore #memory
👍39🔥119
Capture this
#новичкам

Бывают ситуации, когда вы хотите зарегистрировать коллбэк, в котором будет выполняться метод текущего класса.

Например, вы пишите класс приложения. Правила хорошего тона говорят вам, что нужно добавить поддержку graceful shutdown. Для этого получаем объект обработчика сигнала и регистрируем коллбэк на SIGINT и SIGTERM:

struct Application {
Application() {
SignalHandler::GetSignalHandler().RegisterHandler({SIGINT, SIGTERM}, [](){
Shutdown();
});
}

void Shutdown() {...}
};


Сработает ли такой код?

Он не соберется. Будет ошибка: 'this' was not captured for this lambda function. Методу Shutdown нужен объект, на котором его нужно вызвать.

Для этого в С++11 вместе с лямбдами ввели захват this. Это значит, что в лямбду сохраняется указатель на текущий объект. А синтаксис лямбды позволяет в таком случае не использовать явно this в ее теле:

struct Application {
Application() {
SignalHandler::GetSignalHandler().RegisterHandler({SIGINT, SIGTERM}, [this](){
// this->Shutdown(); - don't need this syntax
Shutdown();
});
}

void Shutdown() {...}
};


До С++20 кстати можно было захватывать this в лямбду с помощью дефолтного захвата по значению и по ссылке:

SignalHandler::GetSignalHandler().RegisterHandler({SIGINT, SIGTERM}, [= /*& also works*/](){ // works
Shutdown();
});


Однако в С++20 запретили неявный захват this по значению(что очень хорошо). Теперь либо явных захват this, либо через default capture by reference.

Be explicit. Stay cool.

#cppcore #cpp11 #cpp20
👍225❤‍🔥3🔥2👏1
Почему еще важен std::forward
#опытным

Подписчик @Ivaneo предложил новую рубрику #ЧЗХ, в рамках которой мы будем рассматривать мозголомательные примеры кода и пытаться объяснить, почему они работают так криво.

Также спасибо ему за предоставление следующего примера:

#include <iostream>

void bar(float&& x) { std::cout << "float " << x << "\n"; }
void bar(int&& x) { std::cout << "int " << x << "\n"; }

void foo(auto&& v) { bar(v); }

int main() {
foo(1);
foo(2.0f);
}


Как думаете, что выведется на консоль? Подумайте пару секунд.

Ну нормальный человек ответит:

int 1
float 2


Однако командная строка вам выдаст следующее:

float 1
int 2


Если не верите, по посмотрите в годболте. И можете уже сейчас написать в комментах: "ЧЗХ", "WTF", "WAT" и прочее.

А нам пораразбирацца.

Тут используется auto в аргументах функции, значит эта функция неявно шаблонная. Посмотрим, что нам выдаст cppinsights по этому коду:

#ifdef INSIGHTS_USE_TEMPLATE
template<>
void foo<int>(int && v)
{
bar(static_cast<float>(v));
}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
void foo<float>(float && v)
{
bar(static_cast<int>(v));
}
#endif


Просто прекрасно. Какого черта компилятор кастит переменные к противоположным типам?

Первое, что важно понимать: внутри функции foo переменная v - это уже lvalue, так как имеет имя. Значит просто так вызвать перегрузки для правых ссылок он не может.

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

То есть происходит следующее: компилятор понимает, что подходящей перегрузки нет, поэтому начинает применять стандартные преобразования в другие типы. Любой каст дает временный объект. А временный объект типа int легко биндится к float&&, как и временный объект float легко биндится к int&&.

Вот и получается обмен вызовами.

Чтобы такого не происходило, применяйте перед сном std::forward. Если есть контекст вывода типов, то он помогает правильно передавать категорию выражения объекта во внутренние вызовы.

#include <iostream>

void bar(float&& x) { std::cout << "float " << x << "\n"; }
void bar(int&& x) { std::cout << "int " << x << "\n"; }

void foo(auto&& v) { bar(std::forward<decltype(v)>(v)); }

int main() {
foo(1);
foo(2.0f);
}


В этом случае вывод будет ожидаемым.

Be amazed. Stay cool.

#cppcore #cpp11 #template
🔥46🤯297👍7❤‍🔥2
Just throw it forward
#опытным

Работа с исключениями - целое искусство. В этой теме куча фишек, которые помогут извлекать из исключений максимум пользы.

Пусть вы хотите работать с какой-то сущностью в коде эксклюзивно:

void func(std::shared_ptr<Resource> res) {
if (res->TryToAcquire()) {
// do dirty things
res->Release();
} else {
// you don't have permission to do dirty, so return
return;
}
}


Что будет, если в этом коде в результате обработки эксклюзивно захваченного ресурса произойдет исключение?

Правильно, утечка. Утечка ресурса. Так как вы его явно не освободили через метод Release, вы его больше не сможете захватить и он так и останется в подвешанном состоянии.

Хорошо бы отловить исключение и освободить ресурс. Однако привычная обработка исключений тут особо не поможет. Мы на этом слое вызовов не можем адекватно обработать исключение. У нас просто недостаточно информации и полномочий, чтобы принять решение, как действовать дальше

То есть нам как-то нужно сделать 2 вещи одновременно: дать исключению выйти наружу и освободить ресурс. Плюс к этому хочется залогировать ошибку.

Автоматически освободить ресурс нам поможет RAII обертка, которая освободит ресурс в деструкторе, если его получилось захватить, а залогировать ошибку поможет совместное использование catch + простой вызов "throw".

void func(std::shared_ptr<Resource> res) {
auto raii_res = RaiiWrapper(res);
if (raii_res->TryToAcquire()) {
try {
// do dirty things
} catch (const std::exception& ex) {
Log("Houston, we have a problem");
throw;
}
} else {
// you don't have permission to do dirty, so return
return;
}
}


Инструкция throw делает единственную вещь - пробрасывает то же самое исключение выше по стеку вызовов.

Получается, что можно не до конца обрабатывать исключение, а сделать свои дела и пробросить исключение дальше.

Don't leak your resources. Stay cool.

#cppcore
19👍17🔥8
🔞 Если принимаете сырой указатель, как параметр - не забудьте проверить его на nullptr. Меньше будете голову ломать при будущем дебаге.

🔞 compareData зачем-то принимает параметры по значению. В смысле, понятно зачем: чтобы было double free, а вам было интереснее ревьюить. Лучше все-таки принимать по константной ссылке аргументы.

🔞 Поле buffer публичное как раз для того, чтобы к нему доступ имела compareData. Мы все-таки за инкапсуляцию и мир во всем мире. Поле надо сделать приватным, а compareData другом. А еще лучше убрать compareData и определить нативный оператор сравнения.

Но если очень хочется оставить compareData, то лучше все равно определить оператор, не делать compareData другом класса, и пусть она использует нативный оператор внутри себя без нарушения инкапсуляции.

🔞 Data имеет конструктор от одного аргумента и лучше бы его сделать explicit. В таком случае, чтобы запретить неявные преобразования.

🔞 Многие отметили, что класс стоит пометить как final. Видимо это какой-то кодстайл, чтобы запретить другим классам наследоваться от этого. Причина, как я понял, это отсутствие виртуального деструктора. Поэтому если кто-то хочет отнаследоваться от класса, то он явно видит, что пока классом нельзя пользоваться полиморфно, убирает final и добавляет виртуальный деструктор.

Все же от Data можно безопасно наследоваться, если не использовать потом наследников полиморфно. А при добавлении виртуального метода можно и самому понять, что надо еще и деструктор соотвествующим сделать.

Мне final кажется оверкиллом для одиночных классов в небиблиотечном коде , но тут кто как привык.

Фух, на этом вроде все.

Вот что вышло по итогу исправлений:

class Data final {
private:
std::unique_ptr<char[]> buffer;
size_t buf_size = 0;

public:
Data(const char* input, size_t size)
: buf_size(size) {
if (input == nullptr && size != 0) {
throw std::invalid_argument("Input cannot be null for non-zero size");
}

if (size > 0) {
buffer = std::make_unique<char[]>(size);
std::copy(input, input + size, buffer.get());
}
}

template <size_t N>
explicit Data(const char (&str)[N])
: Data(str, N) {
}

// Копирующий конструктор
Data(const Data& other)
: Data{other.buffer.get(), other.buf_size} {
}

// Универсальное присваивание
Data& operator=(Data other) {
swap(*this, other);
return *this;
}

// Перемещающий конструктор
Data(Data&& other) noexcept {
swap(*this, other);
}

~Data() = default;

// Оператор сравнения
bool operator==(const Data& rhs) const noexcept {
return std::string_view{buffer.get(), buf_size} == std:;string_view{rhs.buffer.get(), buf_size()};
}

// Вспомогательная функция для обмена
friend void swap(Data& first, Data& second) noexcept {
using std::swap;
swap(first.buffer, second.buffer);
swap(first.buf_size, second.buf_size);
}
};

[[nodiscard]] bool compareData(const Data& data1, const Data& data2) noexcept {
return data1 == data2;
}


Пишите, если что забыл.

Critique your solution. Stay cool.

#cppcore #OOP #goodpractice #design
34🔥9👍7❤‍🔥1👎1
Почему важно проверять входные данные
#новичкам

Вы делаете какие-то вычисления и среди них есть целочисленное деление:

void fun(int a, int b) {
// some calculations
auto res = a / b;
// some calculations
}


Вы уже наслышаны о том, что в С++ есть механизм обработки исключений и просто оборачиваете вашу функцию в try-catch и думаете, что С++ позаботится о том, что вы будете информированы о всех проблемах, которые могут произойти в коде:

try {
fun(1, 2);
} catch(std::exception& ex) {
std::cout << "Calculation error: " << ex.what() << std::endl;
}


Однако помните, что вы пишите на С++. Здесь уровень заботы примерно, как у бати, который вывозит неумеющего плавать сына на середину реки и бросает его в воду с криками "Плыви, сынок!".

Если вдруг вы передадите в fun вторым параметром ноль:

try {
fun(1, 0);
} catch(std::exception& ex) {
std::cout << "Calculation error: " << ex.what() << std::endl;
}


то С++ не пошлет исключение об этой ситуации.

Стандарт явно говорит: целочисленное деление на ноль приводит к неопределенному поведению. У меня например программа просто падает с надписью: Floating point exception. Очень иронично со стороны компилятора обрабатывать эту ситуацию, выводя в консоль текст о появлении исключения, хотя его тут нет и его никак не отловить.

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

void fun(int a, int b) {
if (!b) {
throw std::runtime_error("Devision by zero!");
}
// some calculations
auto res = a / b;
// some calculations
}

try {
fun(1, 0);
} catch(std::exception& ex) {
std::cout << "Calculation error: " << ex.what() << std::endl;
}


Тогда на консоль явно выведется ожидаемое Calculation error: Devision by zero!.

Так что будьте осторожны и проверяйте входные данные.

Stay alert. Stay cool.

#cppcore
👍3112🔥11❤‍🔥4🤯3
​​std::to_address
#опытным

В этом посте мы поговорили о том, как доставать настоящий адрес объекта с помощью функции std:addressof. В основном она предназначена для получения настоящего адреса любых объектов, даже тех, у кого перегружен оператор взятия адреса.

Однако есть и другая, похожая задача. Вам приходит на вход объект, который представляет из себя какого-то рода указатель на объект и из него нужно получить адрес самого объекта.

Дело это не совсем тривиальное. Со всеми стандартными классами, типа умных указателей и итераторов(которые называют общим выражением fancy pointer) может прокатить вот такое выражение: obj.operator->(). Однако для простых указателей это не прокатит: они не классы и у них нет методов. Да и не прокатит для любых других объектов, у которых не определен этот оператор. Что делать?

Использовать C++20 функцию std::to_address! Вот ее примерная реализация:

template<class T>
constexpr T* to_address(T* p) noexcept {
static_assert(!std::is_function_v<T>);
return p;
}

template<class T>
constexpr auto to_address(const T& p) noexcept {
if constexpr (requires{ std::pointer_traits<T>::to_address(p); })
return std::pointer_traits<T>::to_address(p);
else
return std::to_address(p.operator->());
}


То есть для указателей она просто вовращает их значения наружу, а для объектов fancy pointer'ов она спрашивает, определено ли свойство std::pointer_traits для этих типов. Если же не определено, то пытается достать указатель с помощью вызова метода operator->().

Обычно эта функция требуется для вызова сишного апи в обобщенном коде:

void c_api_func(const int*);

template<typename T>
void call_c_api_func(T && obj) {
c_api_func(std::to_address(obj));
}

std::vector<int> data{10, 20, 30};
call_c_api_func(data.begin()); // works
auto ptr = std::make_unique<int>(42);
call_c_api_func(ptr); // works
call_c_api_func(ptr.get()); // also works


Use the right tool. Stay cool.

#cpp20 #cppcore
223👍9🔥7😁3
Обогащаем исключение
#новичкам

Еще один простой и известный лайфхак, как можно более эффективно работать с исключениями.

Вот пилишь проект на 100500 строк кода. Запускаешь и вылетает ошибка: "абракадабра, ты хреновый программист". И как понять, откуда это исключение прилетело?

И начинается. Все обкладывается принтами и смотрится после какого принта программа падает. Но можно сделать немного умнее.

Пусть у вас в проекте активно используется библиотека для работы с базой данных. При возникновении непредвиденной ситуации библиотека генерирует исключение. Однако мест, где вы используете базу, много. И вам хотелось бы понимать примерный путь пролетания исключения, чтобы лучше понимать ситуацию для отладки.

В прошлом посте мы просто прокидывали исключение дальше, без добавления в него какой-то информации. Но можно бросить новое исключение и передать туда сообщение об ошибке первого:

void ComplicatedCalculations() try {
// use db
} catch (std::exception& ex) {
throw std::runtime_error(std::string("ComplicatedCalculations Error:") + ex.what());
}

void HandlingCalculations() try {
ComplicatedCalculations();
} catch (std::exception& ex) {
throw std::runtime_error(std::string("HandlingCalculations Error:") + ex.what());
}


В итоге вы на верхнем слое приложения финально обрабатываете все исключения и получаете фактически стек вызовов, которые они пролетают. И уже намного проще найти место конкретной проблемы, особенно если есть информативные логи.

Track your problems to the bottom. Stay cool.

#cppcore
28👍10🔥5
​​Засовываем исключение в исключение
#опытным

Вы знали, что в плюсах есть вложенные исключения? Такие исключения могут хранить в себе несколько исключений. Сегодня мы посмотрим, что это за зверь такой.

Начнем с применения. В прошлом посте мы создавали новое исключение на основе строки ошибки обрабатываемого исключения. В этом случае нужно писать определенное количество бойлерплейта и теряется информация о типе изначального исключения. Чтобы избежать этих проблем, мы можем бросить новое исключение, которое будет в себе содержать старое:

void ComplicatedCalculations() try {
// use db
} catch (std::exception& ex) {
std::throw_with_nested(std::runtime_error("Complicated Calculations Error"));
}

void HandlingCalculations() try {
ComplicatedCalculations();
} catch (std::exception& ex) {
std::throw_with_nested(std::runtime_error("Handling Calculations Error"));
}


Теперь исключение, которое вылетит из HandlingCalculations будет на самом деле содержать 3 исключения: от базы данных, от ComplicatedCalculations и от HandlingCalculations.

Вложенные исключения существуют с С++11 и очень интересно устроены. Рассмотрим несколько упрощенные версии сущностей, которые находятся под капотом механизма вложенных исключений. Есть класс std::nested_exception:

class nested_exception
{
exception_ptr _M_ptr;
public:
/// The default constructor stores the current exception (if any).
nested_exception() noexcept : _M_ptr(current_exception()) { }
...
};


Этот класс ответственен за захват текущего исключения с помощью вызова std::current_exception().

Дальше имеется класс, который хранит в себе все множество исключений:

template<typename Except>
struct Nested_exception : public Except, public nested_exception
{
explicit Nested_exception(const Except& ex)
: Except(ex) { }
};


Объекты этого класса наследуются от nested_exception, в котором захвачено старое исключение, и от Except - нового исключения.

Ну и последний компонент - std::throw_with_nested:

template<typename Tp>
[[noreturn]]
inline void throw_with_nested(Tp&& t)
{
throw Nested_exception<remove_cvref_t<Tp>>{std::forward<Tp>(t)};
}


При вызове throw_with_nested создается объект Nested_exception на основе переданного типа исключения и, неявно, nested_exception, которых сохраняет в себе указатель на старое исключение.

Получается, что мы при каждом вызове throw_with_nested подмешиваем новое исключение к старому с помощью множественного наследования.

Очень прикольная техника, которая позволяет строить цепочки объектов. Это как тупл, только расширяемый в рантайме.

Это все хорошо и интересно. Прокидывать вложенные исключения мы научились. Но рано или поздно их придется обработать. Как это сделать? Об этом будем говорить в следующем посте.

Inherit knowledge from your ancestor. Stay cool.

#cppcore #cpp11
🔥158👍5
Раскрываем вложенное исключение
#опытным

Каша заварилась, пришло время расхлебывать. В прошлом посте мы успешно завернули кучу исключений в одно, теперь нужно как-то все обратно развернуть. Для этого существует функция std::rethrow_if_nested. Работает она вот так:

class nested_exception
{
exception_ptr _M_ptr;
public:
/// The default constructor stores the current exception (if any).
nested_exception() noexcept : _M_ptr(current_exception()) { }

[[noreturn]] void rethrow_nested() const {
if (_M_ptr)
// just throw _M_ptr
rethrow_exception(_M_ptr);
std::terminate();
}
...
};

template<class E>
void rethrow_if_nested(const E& e) {
if (auto p = dynamic_cast<const std::nested_exception*>(std::addressof(e)))
p->rethrow_nested();
}


Так как объект вложенного исключения - это наследник std::nested_exception, то мы имеем право динамически привести указатель переданного в rethrow_if_nested исключения к std::nested_exception. Если каст прошел успешно, то бросается исключение, сохраненное в nested_exception. Если каст провалился, значит E - это уже не наследник nested_exception и у нас в руках самое первое исключение в цепочке.

Обычно в статьях по этой теме приводят мягко говоря странные примеры того, как надо обрабатывать вложенные исключения:

int main() {
try {
Login("test@example.com", "secret");
} catch (SecurityError &e) {
std::cout << "Caught a SecurityError";
try {
std::rethrow_if_nested(e);
} catch (AuthenticationError &e) {
std::cout << "\nNested AuthenticationError: " << e.Email;
}
}
std::cout << "\nProgram recovered";
}


Встречайте вложенные try-catch с заранее известной глубиной вложенности. Это конечно никуда не годится.

В реальности при повсеместном использовании вложенных исключений мы не знаем, сколько раз нам нужно сделать rethrow_if_nested. Но при этом мы точно знаем, что если исключение последнее в цепочке, то rethrow_if_nested ничего не сделает. Это же идеальное условие для остановки рекурсии!

Рекурсия решает проблему неопределенного количества уровней вложенности исключений и помогает удобно подряд печатать соответствующие сообщения об ошибках:

void print_exception(const std::exception &e, int level = 0) {
std::cerr << std::string(level, ' ') << "exception: " << e.what() << '\n';
try {
std::rethrow_if_nested(e);
} catch (const std::exception &nestedException) {
print_exception(nestedException, level + 1);
} catch (...) {
}
}

void ComplicatedCalculations() try {
// use db
} catch (std::exception &ex) {
std::throw_with_nested(std::runtime_error("Complicated Calculations Error"));
}

void HandlingCalculations() try { ComplicatedCalculations(); } catch (std::exception &ex) {
std::throw_with_nested(std::runtime_error("Handling Calculations Error"));
}

int main() {
try {
HandlingCalculations();
} catch (const std::exception &e) {
print_exception(e);
}
}
// OUTPUT:
// Handling Calculations Error
// Complicated Calculations Error
// Some DB Error


В этом случае на добавление дополнительной информации к исключению вы тратите всего 1 строчку, которая мало чем отличается от возбуждения нового исключения. Плюс в коде логики проекта вы одной строчкой обрабатываете все вложенные исключения. Красота!

Очень нишевая функциональность, которую вы вряд ли встретите в реальных проектах. Однако будет прикольно попробовать ее в своих пет-проектах, если вы не гнушаетесь исключений.

Have a good error handling system. Stay cool.

#cppcore #cpp11
2🔥1211👍7🤣1
​​Volatile
#опытным

Ключевое слово, которое не embedded С++ разработчик вряд ли когда-нибудь встречал в код. Сегодня мы поговорим, для чего оно используется.

Предположим, что у нас есть переменная keyboard_press, память под которую замаплена на память устройства ввода-вывода. Когда нажимается кнопка клавиатуры, изменяется переменная keyboard_press. Оставим сам маппинг за скобками и попробуем написать какую-то детсадовскую логику с переменной keyboard_press:

int keyboard_press = 0;
size_t count_test = 0;

void some_function() {
while(keyboard_press == 0) {
count_test++;
}
// doing stuff
}


Что в ассемблере?

some_function():
mov eax, DWORD PTR keyboard_press[rip]
test eax, eax
jne .L1
.L3: // это кстати пустой бесконечный цикл, куда нельзя попасть и откуда нельзя выбраться
jmp .L3
.L1:
ret
count_test:
.zero 8
keyboard_press:
.zero 4


А где цикл? А где инкремент count_test?


На самом деле код собран с -О3 и компилятор просто выкинул цикл. Он не видит, что в данном коде где-то еще изменяется keyboard_press, поэтому разумно полагает, что мы написали бесконечный цикл без сайдэффектов, который вообще-то ub.

Но keyboard_press может изменяться, просто это никак не понятно по коду программы.

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

Однако компилятор точно видит тип переменной. И на него мы можем повлиять. Вот чтобы отучить компилятор от таких фокусов, нужно пометить keyboard_press ключевым словом volatile.

volatile int keyboard_press = 0;
size_t count_test = 0;

// same


Теперь ассемблер выглядит так:

some_function():
mov eax, DWORD PTR keyboard_press[rip]
test eax, eax
jne .L1
mov rax, QWORD PTR count_test[rip]
add rax, 1
.L3:
mov edx, DWORD PTR keyboard_press[rip]
mov rcx, rax
add rax, 1
test edx, edx
je .L3
mov QWORD PTR count_test[rip], rcx
.L1:
ret



Все, что делает volatile - все операции над переменной становятся видимыми спецэффектами и не могут быть оптимизированы компилятором. Ну и еще операции над volitile переменными не могут переупорядочиваться с другими видимыми спецэффектами в порядке кода программы.

Говорится ли здесь что-нибудь о потоках? Нет! Здесь говорится только об оптимизациях компилятора.

Поэтому использовать volatile можно только для обработки сигналов(хэндлер которых вызывается в том же прерванном потоке), либо в тех местах, где вы работаете с переменной строго в одном потоке.

Доступ к volatile переменным не атомарный + с их помощью нельзя делать синхронизацию неатомарных переменных между потоками, так как volitile не подразумевает барьеров памяти.

Именно из-за этих ограничений volatile используется в очень узком спектре задач работы с I/O. Во всех остальных случаях в С++ используются атомики.

Don't be optimized out. Stay cool.

#cppcore #multitasking #memory
2❤‍🔥18👍166🔥4
​​Отличия volatile от std::atomic
#опытным

Кратко пробежимся по особенностям volatile переменных и атомиков, чтобы было side-by-side сравнение.

volatile переменные:

- Компилятору запрещается выкидывать операции над volatile переменными. Грубо говоря, компилятору запрещается "запоминать" значение таких переменных и он обязан их каждый раз читать из памяти.

- Это происходит, потому что операции над volatile переменными становятся видимыми сайд-эффектами. Такие операции в программе влияют на другие потоки и внешние системы. Компилятор в принципе может крутить ваш код на всех продолговатых инструментах, которых он хочет. Главное, чтобы видимое внешнему миру исполнение осталось прежним. Поэтому просто выкинуть из кода использование volatile переменной он не может.

- Физически это значит, что volatile переменные запрещается кэшировать в регистрах и их всегда нужно честно читать из памяти.

- Запрещается реордеринг операций volatile переменных с другими операциями с видимыми спец-эффектами, расположенных выше и ниже по коду.

- Любой другой реордеринг разрешен.

- Операции над такими переменными не являются гарантированно атомарными в том плане, что есть возможность увидеть их промежуточное состояние. В целом, ничто не мешает пометить volatile объект std::unordered_map и конечно же операции над мапой не будут атомарными. Они могут быть атомарными на определенных архитектурах для тривиальных типов с правильным выравниванием памяти, но это никто не гарантирует.

- Запись и чтение volatile переменных не связаны между собой отношением synchronized-with, поэтому на их основе нельзя выстроить межпотоковое отношение happens-before. А это значит, что по стандарту С++ доступ к volatile переменной из разных потоков - это гонка данных и ub.


std::atomic:

- Операции над атомиками - это также видимые спецэффекты, только ситуация немного другая. Запись в атомике и других переменных, синхронизируемые атомиком, становятся видимыми сайд эффектами только для потоков, которые прочитают последнюю запись в атомик.

- По сути, если компилятор докажет, что поток будет всегда читать одно и то же значение атомика, то он может его закэшировать. Если ваш код только читает из memory mapped io, то компилятор теоритически может выкинуть чтение и заменить заранее вычисленным значением. Поэтому атомик нельзя использовать, как замену volatile.

- Вы можете контролировать, какой барьер памяти хотите поставить атомарной операцией, и соответственно можете контролировать реордеринг. Самый сильный порядок предполагает, полный барьер памяти - никакие инструкции до атомика не могут быть переупорядочены ниже по коду и наоборот. Самый слабый порядок не предполагает никаких барьеров.

- Операции над атомарными переменными гарантировано являются атомарными в том смысле, что невозможно увидеть их промежуточное состояние. Это могут быть реально lock-free операции или в кишках операций могут использоваться мьютексы, но все это дает эффект атомарности.

- Запись и чтение атомиков связаны между собой отношением synchronized-with, поэтому на их основе можно построить межпотоковое отношение happens-before. Это значит, что по стандарту операции непосредственно над атомарными переменными не могут приводить к гонке данных.

- При использовании правильных порядков и барьеров памяти вы можете добиться того, что с помощью атомарных переменных вы сможете соединять операции над неатомиками отношением happens-before. Это значит, что атомики можно использовать для корректной синхронизации неатомарных переменных и предотвращения гонки данных над ними.

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

Compare things. Stay cool.

#cppcore #cpp11 #multitasking
16👍11🔥6❤‍🔥1