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

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

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

Если вы приличное время работаете с байтами на низком уровне, вы понимаете, что стандартные сишные возможности репрезентации сырых байтов данных не очень удобные. В основном сложности, конечно, в семантике. Вот возьмём какой-нибудь указатель на чар char *. Что это? Символ, число или просто сырой байт? Да, со временем это уже откладывается на подкорке, все всё понимают, ничего лучше же нет. Или есть?

Что такое std::byte?

std::byte — это фундаментальный тип данных, предназначенный для представления необработанных байтов памяти. Это неотъемлемая часть стандарта C++17, призванная обеспечить стандартизированный способ работы с необработанными двоичными данными. В отличие от базовых числовых типов, таких как char, int или float, std::byte — это отдельный тип, оптимизированный для операций на уровне байтов, что делает его более подходящим для задач, связанных с манипулированием памятью и низкоуровневым программированием.

Откуда оно взялось?

На самом деле все просто. В cтандарте этот тип определяется как enum class byte : unsigned char {} ;

Выглядит просто, но такая сущность давно напрашивалась. В плюсах есть большая необходимость в стандартизированном, независимом от платформы способа манипулирования необработанными двоичными данными, особенно в таких сценариях, как сериализация данных, работа с сетевыми протоколами и взаимодействие с оборудованием. Появление отдельного типа для байтов в C++17 частично решило эти проблемы, так как std::byte:

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

2️⃣ Обеспечивает безопасность при проверке типов. Операции над std::byte выполняются без непреднамеренного преобразования типов, так как нет переопределенных операторов преобразования в базовые типы. Это помогает выявить потенциальные проблемы, связанные с типами, и повышает безопасность кода при работе с данными низкого уровня.

3️⃣ Явно поддерживает только байтовую и битовую арифметику за счёт переопределенных операторов сравнения и битовых манипуляций. Это с одной стороны, ограничивает функционал класса, а, с другой стороны, че вы ещё хотите делать с байтами?

4️⃣ В качестве стандартной фичи C++ использование std::byte обеспечивает безопасность вашего кода на уровне языка.

Есть один единственный минус у этой фичи. Очень мало народу ей пользуется. Большинству существующих проектов на плюсах больше 5 лет и там есть уже свои привычные методы работы с сырой памятью и сишным интерфейсом, которые естественно все завязано на типе char. И только потому, что в стандарте появилась новая фича, никто эти методы изменять не будет. Да и новые проекты могут по инерции использовать старый подход. Он всем знаком и проверен временем.

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

Stay hardcore. Stay cool.

#cpp17 #memory #hardcore
Inline функции

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

Но в любой программе отчетливо просматривается группировка команд по смыслу. То есть определенная группа команд отвечает за выполнение какого-то комплексного действия. Это можно представить в виде графа, где вершины - эти группы, а ребра - переходы между ними. И оказалось очень удобным ввести сущность, отражающую во эту общность набора строк. Такая сущность называется функцией. И чтобы организовывать код с учетом наличия функций, нужны правила, согласно которым их будут вызывать. Так появился стек вызовов, calling conventions и так далее.

Что здесь важно знать. Чтобы выполнить функцию нужно сделать довольно много дополнительных действий. Положить значение base pointer'а на стек, через него же или через регистры передать аргументы, прыгнуть по адресу функции, сохранить возвращаемое значение функции, восстановить base pointer и прыгнуть обратно в вызывающий код. Может что-то забыл, но не суть. Суть в том, что дополнительные действия - дополнительные временные затраты на выполнение. Опять такой trade-off между перфомансом и удобством.

Для человека может быть очень удобно определить функцию сложения двух чисел. Семантически это действительно отдельная операция, которую удобно вынести в отдельную функцию и всегда ей пользоваться. Но с точки зрения машинного кода, затраты на вызов функции вносят значительный вклад в вычисление нужного значения. А вообще-то нам бы хотелось и рыбку съесть и на..., то есть перфоманс не потерять. И такой способ существует!

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

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

Сейчас компилятор настолько преисполнился в своем познании, что может любую функцию сам встроить по своему хотению. А еще может просто проигнорировать вашу пометку inline и не встраивать функцию. Да и вообще, сейчас все методы, которые определены в объявлении класса неявно помечены как inline. С учетом наплевательского отношения компилятора к нашим пожеланиям, кажется, что вообще бессмысленно использовать ключевое слово inline для оптимизации кода. Хотя у inline есть и другое полезное свойство, но об этом в другой раз.

Но помимо бенефитов встраивания кода, у него есть и недостатки.

Из очевидного - увеличение размера бинаря. Код функции можно переиспользовать, а код заинлайненной функции будет располагаться в каждом ее вызове. Больше инструкций - больший размер бинаря.

Из неочевидного - встраивание функций может оказывать повышенное давление на кэш процессора. Например, если функция слишком большая, чтобы поместиться в L1, она может выполниться медленнее, чем при обычном выполнении function call. Для вызова функции CPU может заранее подгрузить ее инструкции и адрес возврата и выполнить ее быстрее. Или например, большое количество одного и того же встроенного кода может увеличить вероятность кэш-промаха и замедлить пайплайн процессора.

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

Stay optimized. Stay cool.

#compiler #optimization #cppcore #performance #hardcore #memory
Базовая формулировка Pimpl Idiom

Пускай у нас есть класс, который выполняет определенную фильтрацию изображения. Для определенности положим, что это фильтр удаления шумов. Для этого нам нужен будет видимый класс NoiseReductionFilter для использования функциональности фильтра, и класс имплементации NoiseReductionFilterImpl, который будет инкапсулировать конкретную реализацию фильтра. Зачем нам вообще нужно такое разделение? Этим классом будет пользоваться потенциально много народу, поэтому мы не хотим раскрывать хоть какие-нибудь детали реализации, чтобы люди не делали своих предположений о реализации и не делали опасных низкоуровневых трюков. Это может навредить нашей интеллектуальной собственности или репутации проекта, если его будут неправильно использовать. Причина немного надутая, но большие проекты просто обязаны заботиться о таких вещах. Окей пишем(осторожно псевдокод):

 // NoiseReductionFilter.hpp
#include "NoiseReductionFilterImpl.hpp"
struct NoiseReductionFilter {
Filter();
private:
NoiseReductionFilterImpl impl;
};
// NoiseReductionFilter.cpp
NoiseReductionFilter::Filter() {
impl.FilterImpl();
}


В чем проблема этого кода? Для начала, мы тут вообще ничего не скрыли. Тип NoiseReductionFilterImpl должен быть определен в момент компиляции и определен он в известном файле NoiseReductionFilterImpl.hpp, к котором все, кому ни попадя имеют доступ. Ни о какой конфиденциальности речи быть не может.

Image processing - очень быстро развивающаяся область. Сейчас все больше проектов переходят с консервативных методов к использованию нейросетей. Поэтому, очевидно, что этот класс тоже будет довольно активно развиваться. А мы хотим поддерживать ABI совместимость с проектами пользователей и не ломать их проекты своими новыми версиями. В данном случае такого не получится сделать, потому что очень большой перечень изменений может сломать ABI и реально поменять реализацию без поломки бинарной совместимости невозможно.

Какое здесь может быть решение проблемы?

Убрать подключение NoiseReductionFilterImpl.hpp и сделать закрытый член класса указателем на тип NoiseReductionFilterImpl. Но раз мы убрали заголовочник с объявлением типа, тогда мы не можем использовать указатель на этот тип. Или можем?

Еще как можем. Есть такое понятие, как forward declaration. Мы можем сказать компилятору, что есть вот такой класс NoiseReductionFilterImpl и мы даём слово, что опишем и определим его, но пока не скажем тебе где. И тогда мы можем объявить приватный член класса, как указатель на NoiseReductionFilterImpl, но никак не использовать его. И нам это сойдет с рук. Компилятор потом сам отыщет определение этого класса и удачно разрезолвит все символы. Сейчас покажу, как это будет выглядеть.

 // NoiseReductionFilter.hpp
struct NoiseReductionFilter {
Filter();
private:
struct NoiseReductionFilterImpl; // forward declaration
NoiseReductionFilterImpl * impl;
};

// NoiseReductionFilter.cpp
struct NoiseReductionFilter::NoiseReductionFilterImpl {
// implement functionality
};

NoiseReductionFilter::NoiseReductionFilter() : impl (new NoiseReductionFilter::NoiseReductionFilterImpl){}

NoiseReductionFilter::~NoiseReductionFilter() {
delete impl;
impl = nullptr;
}

void NoiseReductionFilter::filter() {
impl->FilterImpl();
}


Скажу сразу, этот код нельзя использовать как он есть здесь. Он просто, чтобы показать концепцию, и не использует необходимых фичей новых стандартов. Что здесь происходит. Мы вынесли определение в файл реализации. А значит никто, кроме нас не сможет увидеть или даже намек почуять, как реализована функциональность (не берем в расчет реверс-инженеров). Это будут просто инструкции, которые будут подгружаться во время выполнения кода клиента.

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

Но есть и негативные последствия использования идиомы.

Продолжение в комментах…

#cppcore #design #howitworks #memory
Категории выражений

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

Наверняка в начале изучения языка вам приходилось сталкиваться с фундаментальными понятиями, такими как присвоение значения чему-либо:
  
int a, b;

a = 3; // Корректно
b = a; // Корректно
3 = b; // Ошибка


Исходя из этого простого примера можно сделать вывод, что нельзя просто так взять и присвоить 3 какое-то новое значение. Хотя, казалось бы, это должно быть очень веселым занятием 😊 Напрашивается вопрос, можно ли как-то классифицировать выражения по действиям над ними? Существуют ли еще какие-то особые правила?

Такая классификация действительно возможна и она называется категориями выражений. Итак, встречайте:

lvalue
Так называются те выражения, которыМ задают значение. Они должны быть модифицируемые. Зачастую они располагаются слева от знака равенства, поэтому и получили такое название left-hand value.
  
lvalue
a = 3;


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

rvalue
К этой категории относятся выражения, которыЕ задают значения. Обычно они расположены справа от знака равенства - отсюда название right-hand value.
        rvalue
a = b;


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

prvalue
К этой категории относятся выражения, которые только задают значения. К такой категории относятся constexpr, литералы и т.д. Например:
        prvalue
a = 3;


Они являются подмножеством rvalue, и в дальнейшем мы не будем делать на этом акцент.

xvalue
К этой категории относятся временные выражения, которые будут в скором времени уничтожены (eXpiring value). В некоторых случаях, их ресурсы могут быть эффективно переиспользованы. Пока оставлю вас без примера 😉


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

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

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

Так, например, мы знаем, что нет никаких ограничений, чтобы скопировать переменную a в b. Значит, переменная a может быть преобразована к rvalue :
  
lvalue rvalue
a = 3;

lvalue lvalue -> rvalue
b = a;


Действительно, lvalue может быть неявно приведено к rvalue, но не наоборот! Так, например, численная константа 3 независимо от контекста всегда будет rvalue, т.к. её значение нельзя поменять ни при каких обстоятельствах. Если это правило нарушается, компилятор вполне заслуженно бьет по рукам.

Рассмотрим другой пример:
rvalue     rvalue 
(a + b) = a // Ошибка!


Хоть сумма a + b и может быть образована из двух lvalue, но оператор + возвращает rvalue. Результат сложения должен быть присвоен другой переменной или использован для других операций. По сути, он не был сохранен в переменную на стек или кучу из области видимости, поэтому как ему можно присвоить хоть какое-то иное значение?

Продолжение в комментариях!

#cppcore #memory #algorithm
CV-специфицированные значения

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

В С++ есть способы наложить дополнительные ограничения на действия над данными. Например, запретить пользователю изменять значения с помощью ключевого слова const. Вероятно, что это как-то должно повлиять на категорию выражения, не так ли?

Стандарт языка использует термин «cv-специфицированный» для описания типов с квалификаторами const и volatile. Пример:
// Запрещаем изменять значение
const int a = 1;

// Запрещаем кешировать значение в регистрах
volatile int b = 2;

// Комбинация двух предыдущих
const volatile int c = 3;


Про const вы, наверняка, уже знаете. Вот о квалификаторе volatile мы еще не говорили, от нас тут нужна хорошая подводка... В рамках этой темы достаточно знать, что volatile переменные всегда должны лежать в оперативной памяти (т.н. запрет на кеширование значений; запрет на оптимизацию).

Стоит подумать, для каких категорий выражений такие квалификаторы будут приносить пользу? Ограничить возможность изменять значение или запретить кеширование логично для lvalue:
// Returns const reference 
// to access for reading only
const std::string& foo() { return lvalue; }

// Accepts const reference
// to access for reading only
void bar(const std::string &lvalue)

// Spawns read-only value
const int magic = 3;


Несмотря на то, что переменной magic нельзя присвоить новое значение, она всё ещё принадлежит категории lvalue:
const int magic = 3; 

// lvalue rvalue
magic = 5;
// ~~^~~
// Error: assignment of
// read-only variable 'magic'


Нельзя сказать, что неизменяемый тип является rvalue. Нет, это просто другое свойство, которое накладывает ограничения на действия над данными. Однако, такие выражения могут быть использованы только как rvalue. Т.е. могут быть только прочитаны, скопированы. Это позволяет ослабить ограничения в таких ситуациях:
const int &d = 2; // Ok


Это может показаться странным, ведь d должна ссылаться на какое-то значение в памяти. Да и в остальных случаях это работает иначе:
int  a = 1; // Ok
int &b = a; // Ok
int &c = 2; // Error!


В отношении с все вполне логично и понятно — нельзя сослаться и изменять память, которая не выделена под неё. Почему же всё работает для d? Тут мы видим, что эти данные запрещено изменять и нет запрета на кеширование. Следовательно, при соблюдении этих ограничений дальше, выражение может быть использовано только как rvalue, т.е. без перезаписи значений в памяти. Компилятор либо подставит это значение по месту требования, либо создаст вспомогательную локальную копию. В общем случае, ни логика, ни работоспособность приложения не нарушится. Живой пример

Априори, в совокупности с volatile квалификатором такой трюк не прокатит из-за требований volatile:
const volatile int &f = 4; // Error!


Конечно, неприятный казус может случиться, если мы попытаемся обойти это ограничение — применим const_cast<int&>, т.е. осознанно выстрелим себе в ногу снимем ограничение на изменение данных. По сути, это прямое игнорирование ограничений, которые по каким-то причинам вводились в код проекта ранее. И вот желательно их выяснить и обойти иначе, а не использовать такие грязные трюки. Короче, это UB!

Наглядный пример, почему использование этого каста является дурным тоном в программировании на C++: https://compiler-explorer.com/z/qK1z3q89q. В общем, на языке переживших новогодние праздники: «главное не смешивать»

У меня есть офигенная кружка! Обожаю пить из неё кофе, пока пишу эти посты.

#cppcore #memory #algorithm
Категория выражений xvalue

Да кто этот ваш xvalue?! В продолжение к предыдущим постам.

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

Как уже было сказано однажды, к категории xvalue относятся временные выражения, ресурс которых можно перераспределить после их уничтожения.

Все это звучит как-то абстрактно, давайте глянем пример:
1. Существует временный объект класса string, который хранит 10 Мб текста на куче.
2. Строчку хотят сохранить в другом объекте, а временный объект удалить.

В прямой постановке задачи, мы как раз оперируем категориями lvalue и rvalue:

std::string nstr = tstr;
// ~~^~~ ~~^~~
// lvalue lvalue -> rvalue

// Then destroy temporary string 'tstr'


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

По сути, это и есть те накладные расходы, которые тормозят нашу программу. Кажется, что этого можно избежать. Например, можно сказать другому объекту, что теперь он новый владелец данных временного объекта! То есть мы передадим другому объекту указатель на текст и сделаем так, чтобы временный объект его не удалял. Новый объект сможет дальше продолжить пользоваться текстом, возможно, очень долго, когда старый уже исчезнет. Формально, поменяется лишь оболочка над текстом.

Исходя из этой логики пример может быть эффективно решен следующей последовательностью действий:
1. Инициализируем новый объект string, скопировав указатель на текст и счетчики размера из временного объекта.
3. Во временном объекте установим указатель на текст nullptr и занулим счетчики размера строки, чтобы при вызове деструктора наши данные не потёрлись.
4. Разрушим временный объект.
5. Радуемся новому объекту, которых хранит ресурсы временного объекта!

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

Начиная с C++11 вводится специальная категория выражений для обработки таких временных объектов — xvalue. Так же вводится специальный тип rvalue reference, для которого можно добавить перегрузки операторов и конструкторов:
class string
{
public:
// Constructor for
// rvalue reference of string 'other'
string(string &&other) noexcept
{ ... }

// Assign operator for
// rvalue reference of string 'other'
string& operator=(string &&other) noexcept
{ ... }
};


⚠️ Ранее мы использовали rvalue, как имя категории выражений. Теперь появляется ТИП rvalue reference, который относится к категории выражения xvalue. Не путайтесь, пожалуйста! Я считаю это неудачной терминологией стандарта, которую надо просто запомнить.

Тип rvalue reference задаётся с помощью && перед именем класса. Например:
std::string &&value      = other;
// ~~^~~
// rvalue reference


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

Обратите внимание, как легко и непринужденно тут проявляется идиома RAII. Жизненный цикл объекта остается неизменным и предсказуемым, а ресурсы передаются между объектами: один создал строчку, а другой её удалит.

Будь я на вашем месте, мне бы стало непонятно, как же использовать всю эту лабуду? Продолжение в комментарии!

#cppcore #memory #algorithm
Универсальные ссылки

Вообще говоря, вся эта серия постов началась с просьбы нашего подписчика Сергея Нефедова объяснить зачем нужны универсальные ссылки. Дождались! 🤩

В предыдущей статье я сделал акцент:

Тип rvalue reference задаётся с помощью && перед именем класса.

ОДНО БОЛЬШОЕ НО! Вместо имени класса может быть установлен параметр-тип шаблона:
template<typename T>
void foo(T &&message)
{
...
}


Ожидается, что из него будет выведен тип rvalue reference, но это не всегда так. Такие ссылки позволяют с одной стороны определить поведения для работы с xvalue, а с другой, неожиданно, для lvalue.

В своё время Scott Meyers, придумал такой термин как универсальные ссылки, чтобы объяснить некоторые тонкости языка. Рассмотрим на примере вышеупомянутой foo:
std::string str = "blah blah blah";

// Передает lvalue
foo(str);

// Передает xvalue (rvalue reference)
foo(std::move(str));


Оба вызова функции foo будут корректны, если не брать во внимание реализацию foo. Живой пример

Универсальная ссылка (т.н. universal reference) — это переменная или параметр, которая имеет тип T&& для выведенного типа T. Из неё будет выведен тип rvalue reference, либо lvalue reference. Это так же касается auto переменных, т.к. их тип тоже выводится.

Расставляем точки над i вместе со Scott Meyers:
Widget &&var1 = someWidget;
// ~~^~~
// rvalue reference

auto &&var2 = var1;
// ~~^~~
// universal reference

template<typename T>
void f(std::vector<T> &&param);
// ~~^~~
// rvalue reference

template<typename T>
void f(T &&param);
// ~~^~~
// universal reference


В соответствии с этим маленьким нюансом поведение может меняться внутри функции foo. Банально, можно накодить тормозящее копирование вместо производительной передачи ресурса.

Я немного изменил предыдущий пример: https://compiler-explorer.com/z/EzddYhjdv. В зависимости от выведенного типа, строка будет либо скопирована, либо перемещена. Соответственно, в области видимости функции main объект либо выводит текст, либо нет (т.к. ресурс был передан другому объекту внутри foo).

Причем, это не работает, если T — параметр-тип шаблонного класса:
template<class T>
class mycontainer
{
public:
void push_back(T &&other) { ... }
~~~^~~~
rvalue reference
...
};


Пример: https://compiler-explorer.com/z/We4qzG5xG

Получается, что в универсальные ссылки заложен дуализм поведения. Зачем же так было сделано? А за тем, что существуют template parameter pack:
template<class... Ts>
void foo(Ts... args)
{
bar(args...);
}

foo(std::move(string), value);
~~~~^~~~ ~~^~~~
xvalue lvalue


Как мы видим, разные аргументы вызова foo могут относиться к разным категориям выражений.

Кстати, если не знать и не пытаться в эти тонкости, то можно вполне спокойно использовать стандартные структуры. Если говорить с натяжкой, то можно, конечно, сказать, что такая универсальность может снижать порог вхождения в C++. Не знаешь — пишешь просто рабочий код, а знаешь — пишешь ещё и эффективный.

Другое дело, непонятно, почему нельзя было для универсальных ссылок сделать отдельный синтаксис? Например, добавить T &&&. Т.к. сейчас это рушит всю концептуальную целостность системы типов. Если это планировалось как гибкий механизм, то он граничит с полной дезориентацией разработчиков 😊

Я думаю, что нам еще нужны посты на разбор этой темы, чтобы это в голове уложилось. А пока будем развивать тему в сторону move семантики. Не забываем об исключениях в перемещающем конструкторе, а так же про оптимизации RVO/NRVO.

#cppcore #memory #algorithm #hardcore
Идеальная передача — perfect forwarding

В продолжение к предыдущему посту.

Мы теперь знаем, что универсальные ссылки могут работать с разными категориями выражений lvalue и xvalue. При написании кода шаблонной функции мы можем не знать, какие аргументы могут быть переданы в неё. Соответственно, мы не знаем, можем ли мы распоряжаться её внутренними ресурсами. Всё это сильно влияет на производительность нашего решения. Что же делать в такой ситуации?

Конечно, как вы уже знаете, мы можем детектировать тип rvalue reference. И да, мы можем написать два разных участка кода для двух разных категорий выражений. Можно, но нужно ли? Это противоречит дублированию кода.

Функция std::forward используется для так называемой идеальной передачи аргументов при вызове других методов, конструкторов и функций:
template<typename T>
void foo(T &&message)
{
T tmp(std::forward<T>(message));
...
}


В данном примере во временный объект tmp будет передано либо lvalue, либо xvalue. Следовательно, мы либо скопируем строку, либо переместим. Это зависит от того, как вызвали foo:
std::string str = "blah blah blah";

// Передает lvalue => std::string tmp(str);
foo(str);

// Передает xvalue => std::string tmp(std::move(str));
foo(std::move(str));


То есть std::forward выполняет проброс информации о категории выражения внутрь. Отсюда и название: forward, т.е. дальше.

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

Пример I. Инициализация объекта по универсальной ссылке:
template<class T>
class wrapper
{
std::vector<T> m_data;
public:
template<class Y>
wrapper(Y &&data)
: m_data(std::forward<Y>(data))
{
// make a copy from `data` or move resources from `data`
}
};


Пример II. При работе с контейнерами STL я предпочитаю использовать семейство функций emplace, т.к. они предоставляют возможность сконструировать объект сразу там, где он будет потом храниться. В основе таких методов лежит std::forward, который пробрасывает аргументы вплоть до конструкторов. Смотрите сами тут.

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

Не забываем об исключениях в перемещающем конструкторе, а так же про оптимизации RVO/NRVO.

#cppcore #memory #algorithm
Исключения в перемещающем конструкторе

Продолжаем серию постов. Как вы могли заметить, во всех примерах с перемещающим конструктором был поставлен спецификатор noexcept:
class string
{
public:
string(string &&other) noexcept
{ ... }
};


И неспроста я это делал! Я бы даже сказал, что где-то это является очень важным требованием.

Возьмем в качестве примера всем нам известный std::vector. Одним из свойств этой структуры данных является перевыделение памяти большего размера, при увеличении количества объектов. При этом старые объекты отправляются в новый участок памяти. Логично задаться вопросом — как? И логично ответить, что в целях повышенной производительности нужно выполнять перемещение каждого объекта, а не копирование, если есть возможность.

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

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

Представим ситуацию, что МЫ - ВЕКТОР. Вот мы выделили новую память и начали туда перемещать объекты. И где-то на середине процесса получаем исключение при перемещении одного из объектов. Что нам делать-то с этим? Вообще говоря, надо разрушить все что переместили в новой памяти и сообщить об этом пользователю. Т.е. откатить все назад. НО! Назад дороги нет 😅 Разрушать объекты из новой области памяти нельзя — их ресурсы перемещены из старой памяти. Обратно перемещать тоже нельзя — вдруг опять исключение прилетит? Брать на себя ответственность сделать что-то одно тоже нельзя — мы вектор из стандартной библиотеки. В общем, встаем в аналитический ступор...

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

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

Если мы тоже хотим по возможности не нести ответственность за касяки стороннего класса, то нам приходит на помощь функция:
std::move_if_noexcept(object);


Она делает всё то же самое, что и классическая std::move, но только если перемещающий конструктор помечен как noexcept (или кроме перемещающего конструктора нет альтернатив). А вот если внутри метода, помеченного noexcept, исключение всё таки будет брошено, то будет все очень очень плохо... Скажу по опыту, такое отладить достаточно тяжело. Поговорим об этом, когда наступит время серии постов про исключения 😉

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

Надеюсь, что мне удалось вас убедить в важности noexcept в перемещающем конструкторе. Осталось совсем немного - оптимизации RVO/NRVO.

#cppcore #memory #algorithm
Оптимизации RVO / NRVO

Всем привет! Настало время завершающего поста этой серии. Сегодня мы поговорим об одной из самых нетривиальных оптимизаций в С++.

Я очень удивлюсь, если встречу человека, который по мере изучения стандартных контейнеров никогда не задумывался, что эти ребята слишком «жирные», чтобы их просто так возвращать в качестве результата функции или метода:
std::string get_very_long_string();

...и приходили к мысли, что нужно заполнять уже существующий объект:
void fill_very_long_string(std::string &);

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

Существует такие древние оптимизации, как RVO (Return Value Optimization) и NRVO (Named Return Value Optimization). Они призваны избавить нас от потенциально избыточных и лишних вызовов конструктора копирования для объектов на стеке. Например, в таких ситуациях:
// RVO example
Foo f()
{
return Foo();
}

// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}

// Foo no coping
Foo obj = f();


Давайте взглянем на живой пример 1, в котором вызов конструктора копирования явно пропускается. Вообще говоря, эта информация немного выбивается в контексте постов, посвященных move семантике C++11, т.к. это работает даже на C++98. Вот поэтому я её называю древней 😉

Немного теории. При вызове функции резервируется место на стеке, куда должно быть записано возвращаемое значение функции. Если компилятор может гарантировать, что функция возвращает единственный локальный объект, тип которого совпадает с lvalue, тогда он может сразу сконструировать этот объект напрямую в ожидаемом месте вызывающего кода. Допустимо отличаться на константность.

Иными словами, компилятор пытается понять, можно ли "подсунуть" область памяти lvalue при вычислении rvalue и гарантировать, что мы получим тот же результат, что и при обычном копировании. Можно считать, что компилятор преобразует код в следующий:
void f(Foo *address)
{
// construct an object Foo
// in memory at address
new (address) Foo();
}

int main()
{
auto *address = reinterpret_cast<Foo *>(
// allocate memory directly on stack!
alloca(sizeof(Foo))
);

f(address);
}


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

RVO отличается NRVO тем, что в первом случае выполняется оптимизация для объекта, который создается при выходе из функции в return:
// RVO example
Foo f()
{
return Foo();
}


А во втором для возвращаемого именованного объекта:
// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}


Но при этом замысел и суть остаются такими же! Тут важно отметить, что и вам, и компилятору, по объективным причинам, намного проще доказать корректность RVO, чем NRVO.

Давайте покажу, когда NRVO может не сработать и почему. Рассмотрим кусочек из живого примера 2:
// NRVO failed!
Foo f(bool value)
{
    Foo a, b;
   
    if (value)
        return a;
    else
        return b;
}


Оптимизация NRVO не выполнится. В данном примере компилятору будет неясно, какой именно из объектов a или b будет возвращен. Несмотря на то, что объекты БУКВАЛЬНО одинаковые, нельзя гарантировать применимость NRVO. До if (value) можно было по-разному поменять каждый из объектов и их память. Или вдруг у вас в конструкторе Foo зашит генератор случайных чисел? 😉 Следовательно, компилятору может быть непонятно куда надо конструировать объект напрямую из этих двух. Тут будет применено копирование.

Продолжение в комментариях!

#cppcore #memory #algorithm #hardcore
shared_ptr и массивы

Есть одна не самая приятная вещь при работе с std::shared_ptr. С момента его выхода в С++11 и в С++14 он не может быть использован из коробки для того, чтобы хранить динамические массивы. По дефолту во всех случаях при исчерпании ссылок на объект, шареный указатель вызывает оператор delete. Однако, когда мы аллоцируем динамический массив new[], мы хотим вызвать delete[] для его удаления. Но shared_ptr просто вызовет delete. А это неопределенное поведение.

То есть я не могу просто так вот взять и написать

shared_ptr<int[]> sp(new int[10]);


Кстати говоря, у его собрата std::unique_ptr с этим все получше. У него есть отдельная частичная специализация для массивов. Поэтому вот так я могу написать спокойно:

std::unique_ptr<int[]> up(new int[10]); // вызовется корректный delete[]


Что можно сделать, чтобы таки использовать сишные массивы с шареным указателем?

👉🏿 Обернуть указатель на массив в класс и шарить уже объекты этого класса. Типа того(упрощенно):
template <class T>
struct DynamicArrayWrapper {
DynamicArrayWrapper(size_t size) : ptr{new T[size]} {}
~DynamicArrayWrapper() {delete[] ptr;}
T * ptr;
};

std::shared_ptr<DynamicArrayWrapper> sp{10};


У такого метода есть 2 проблемы. Первое - прокси класс. Дополнительные обертки увеличивают объем и сложность кода и затрудняют его понимание. Второе - перформанс. Здесь уже два уровня индирекции, что замедлит обработку.

👉🏿 Передать свой кастомный делитер. Тут тоже несколько вариантов.
⚡️Написать свой:
template< typename T >
struct array_deleter
{
void operator ()( T const * p)
{
delete[] p;
}
};

std::shared_ptr<int> sp(new int[10], array_deleter<int>());


⚡️Использовать лямбду:
std::shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });


⚡️Ну или воспользоваться уже готовым вариантом:
std::shared_ptr<int> sp(new int[10], std::default_delete<int[]>());

std::default_delete имеет частичную специализацию для массивов.

Но! Какой хороший все-таки стандарт С++17, который поправил многие такие маленькие косячки. А как он это сделал - увидим в следующий раз)

Be comfortable to work with. Stay cool.

#cpp11 #memory
Исправляем косяк std::shared_ptr с массивами

Ну не мы сами, конечно. Стандарт С++17 исправляет этот момент.

Что мы теперь имеем.

Для создания объекта таким конструктором:

template< class T >   
explicit shared_ptr( T* ptr );


используется делитер delete ptr, если T - не массив, и delete[] ptr если Т -массив.

Также теперь изменился тип хранимого объекта element_type. Раньше был просто шаблонный тип Т, теперь же это

using element_type = remove_extent_t<T>;


std::remove_extent - это такой type_trait. Все, что нужно о нем знать - если Т - массив, то тип element_type будет совпадать с типом элементов массива.

Теперь мы даже можем использовать operator[] для доступа к элементам массива. Делается это так:

std::shared_ptr<int[]> num(new int[10]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
for (std::size_t i = 0; i < 10; ++i)
std::cout << num[i] << ' ';


Так что теперь это действительно полноценные шареные массивы из коробки. Весь интерфейс подогнали под это дело.

Но вот вопрос: а нафига это вообще надо? Когда кто-то вообще в последний раз использовал динамический массив?

Мы же вроде на плюсах пишем. Есть плюсовые решения - std::vector, если размер не известен на момент компиляции, и std::array, если известен. У них и интерфейс удобный и унифицированный и все-таки это объектно-ориентированный подход. И сердцу тепло, и глаз радуется. Динамические массивы выглядят, как окаменелые какашки динозавров.

C std::array соглашусь. Думаю, что нет адекватных оправданий использования динамических и статических массивов, длина которых известна в compile-time. std::array - очень простая и тонкая обертка над статическим массивом и ее использование вырождается компилятором до использования массива.

Но вот с векторами немного сложнее. Удобство требует жертв. Именно в плане производительности. Поэтому в узких бутылочных горлышках, где надо выжимать всю скорость из кода, лучше использовать динамические массивы вместо std::vector. Видел запрос от Захара на пример, который подверждает эту мысль. Отвечу на него в другом посте как-нибудь. Но обычному бэкэндеру, думаю, это сильно не пригодится.

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

Fix your flaws. Stay cool.

#cpp17 #memory
std::make_shared в С++20

Начиная со стандарта С++11 в С++ появилась поддержка создания std::shared_ptr при помощи фабричной функции std::make_shared. У нас даже есть пост про особенности этой функции вот здесь. Но у нее были такие же недостатки, как и у std::shared_ptr до С++17. Нельзя было ее использовать для массивов. Но, как отметил уже в комментах Константин, начиная с С++20 эта фабричная функция синхронизировалась со своим вдохновителем и теперь тоже поддерживает создание массивов из std::shared_ptr. Например:

⚡️ std::shared_ptr<double[]> shar = std::make_shared<double[]>(1024): создает std::shared_ptr c 1024 значениями типа double, проинициализированными по умолчанию;

⚡️ std::shared_ptr<double[]> shar = std::make_shared<double[]>(1024, 1.0): создает std::shared_ptr c 1024 значениями типа double, проинициализированными значениями, равными 1,0.

Как обычно make функции немного тормозят относительно типов, для которых они созданы. Типа std::make_unique появился только в с++14, хотя сам уникальный указатель был представлен в предыдущем релизе. Но главное, что эти особенности все-таки доезжают, что не может не радовать.

Enjoy small things. Stay cool.

#cpp20 #memory
​​Как передать в поток ссылку на объект?

Глупый вопрос на первый взгляд. Ну вот есть у вас функция

void RunSomeThread(const & SomeType obj) {...}


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

std::thread thr(RunSomeThread, obj);


Запускаете прогу, все нормально работает, вы довольный пьете кофеек. Но решаете проверить логи. Так, на всякий случай. А вы очень не хотите лишних копирований объектов SomeFunckingType. Поэтому логируете создание этих объектов. И в логах обнаруживаете странную штуку: ваш объект скопировался. WTF???

Дело в том, что новосозданный поток копирует аргументы своего конструктора в свой внутренний сторадж. Зачем это нужно? Проблема в том, что параметры, которые вы передали, могут не пережить время жизни потока и удалиться до его завершения. Тогда обращение к ним по ссылке вело бы к неопределенному поведению. Но копирование выполняется только для тех параметров, которые переданы по значению. Если передавать параметр по ссылке, то ссылка передастся во внутренний сторадж потока без копирования. Это нужно делать только тогда, когда вы на 100% уверены, что ваш аргумент переживет цикл жизни потока.

"Но я же передал obj по ссылке!" Погоди....

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

А сделать это очень просто. С помощью std::ref. Эта функция оборачивает ваш объект в другой шаблонный класс std::reference_wrapper, который хранит адрес вашего объекта. Теперь вы можете написать вот так:

std::thread thr(RunFuckingThread, std::ref(obj));


И никакие копирования вам не страшны! Копируется как бы этот объект, но он хранит указатель на ваш оригинальный объект, поэтому вы и имеете доступ непосредственно к нему.

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

Stay conscious. Stay cool.

#concurrency #cppcore #memory