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

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

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

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

Ответ прост. Можно. Но не в плюсах)

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

1️⃣ Язык C++ имеет совместимость с языком C и является его логическим приемником. Поэтому он стремится быть эффективным и близким к машинному коду. Полноценная рефлексия встроенная в язык требует дополнительные значительные вычислительные и ресурсные затраты, что может не соответствовать целям C++ для высокой производительности и низкого уровня абстракции. Как говорится, не плати за то, чем не пользуешься(слоган эмбеддед разработчиков по жизни).

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

Тем не менее плюсы медленно, но верно продвигаются в расширении рефлексивной функциональности. Мало того, что шаблонная магия позволяет из коробки делать очень многие вещи, так и в современных стандартах появляются такие штуки, как std::any, std::experimental::source_location и тд. Вряд ли когда-нибудь завезут что-то стандартное, подходящее всем и для любых целей, если за 40 лет этого так и не случилось ни с одной библиотекой или модулем. Но тенденция явно позитивная. А в пропоузале к С++26 есть даже целый раздел, посвященный статической рефлексии. Так что per aspera ad astra.

Stay optimistic. Stay cool.

#howitworks #hardcore
PImpl Idiom

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

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

В общем, попытаюсь объяснить. В одном посте все не уместится. Так как деталей ОЧ МНОГО. Давайте для начала разберемся со сферой применения исходя из преимуществ.

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

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

На этом, обычно, преимущества заканчиваются. Но, на мой взгляд, тут упущено главное преимущество. ABI обратная совместимость. ABI - Application Binary Interface. Этот термин из той же бочки, что и API. Библиотека - бинарно совместима, если программа, динамически связанная с предыдущей версией библиотеки, продолжает работать с более новыми версиями библиотеки без необходимости перекомпиляции. Если программа нуждается в перекомпиляции, но не нуждается в модификации исходников, то библиотека - совместима по исходному коду. Первое утверждение относится к ABI, второе к API. Видим, что ABI накладывает более жесткие требования к интерфейсу библиотеки. Действительно, я не хочу заново компилировать свой суперхорошо работающий проект только для того, чтобы мочь использовать новую версию библиотеки. А если я использую 10 библиотек? А если 100? Мне нужно за каждой следить и при установке новой версии пересобирать бинарь? Это уже слишком. Сохранение ABI экономит кучу времени, сил и ограждает от проблем. Это делает распространение ПО намного более легким занятием.

И вот тут важный момент. Главным преимуществом Pimpl выделяют уменьшение времени компиляции. Изменение в impl классе никак не будут влиять на код, который использует публичный класс. Поэтому такой код перекомпилировать ненужно, нужна только новая версия объектного файла класса impl. И это сокращает время компиляции. Но это же всего лишь следствие сохранения бинарной совместимости вашего класса с пользовательским кодом. Вдумайтесь: главным преимуществом выделяют следствие другого преимущества, которое даже не выделяют. Это конечно смех. Если вы поняли это, просто читая информацию только из ру сегмента - вы герой.

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

Stay aware. Stay cool.
Application Binary Interface

Вчера разобрали, что обратная совместимость ABI играет значительную роль при разработке shared библиотек. Но это только применение этого понятие, сам термин мы еще не разбирали. Сегодня исправим этот момент.

ABI - набор правил, которые определяют соглашения о вызовах и расположение структур(стека, ваших кастомных и тд) в памяти. Соглашение о вызовах, наверное, здесь центровую роль играет. В общих словах, это какие операции нужно делать, чтобы выполнить функцию. Компьютер на самом деле не знает, что такое "выполнить функцию". Он знает лишь небольшой набор команд. Типа сложить, переместить, прыгнуть. То, как передаются аргументы для функции - через стек или через регистры, в каком порядке передаются аргументы, как очищать регистры, куда сохранять возвращаемое значение, и определяет calling convention. Но это что-то низкоуровневое, нам бы поближе к коду.

Сравним с API. Если API говорит, что вот есть такой-то набор функций и вы можете их использовать. То ABI говорит, как вы должны этими функциями пользоваться. Ну не вы, а скомпилированный код. Можно представить себе, что все детали того, что нужно сделать процессору, чтобы выполнить ваш код - это и есть ABI. То есть, как API, только на уровень ниже.

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

Приведу некоторые изменения в коде, которые могут аффектить ABI.

👉🏿 Добавление, удаление и изменение порядка полей класса.

👉🏿 Изменение иерархии классов. Данные всех базовых классов лежат в определенном порядке, изменение иерархии влечет изменения в том, как данные объекта располагаются в памяти.

👉🏿 Изменение шаблонных аргументов в шаблонных классах. Это влияет на то, какое mangled имя будет у класса, и соответсвенно, то как к нему обращаться.

👉🏿 Объявление функции как inline. Компилятор может встроить такую функцию и ее имени просто больше не будет в списке доступных функций.

👉🏿 Изменение сигнатуры функций, включая cv-квалификаторы. Тоже по причине манглинга.

👉🏿 Добавление первого виртуального метода. Обычно внутри объекта появляется vptr, это ведет к изменению расположения объекта в памяти.

👉🏿 Изменение порядка объявления виртуальных методов. В таблице виртуальных функций они располагаются по порядку и вызываются по порядку. Изменив порядок можно вызвать не тот метод.

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

👉🏿 И еще куча приколюх с наследованием.

Деталей очень много и списочек там реально очень большой. Я привел только самую верхушку, которую все понимают.

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

При использовании pimpl мы избегаем такого исхода. Указатель - он и в Африке указатель. На заданной платформе имеет один и тот же размер. И пока вы не делаете этих "опасных" изменений в публичном классе, его структура никак не изменяется. А обычно таких изменений не делают, потому что там оставляют только базовый API, который очень стабилен.

Вот такие пироги. Надеюсь, этот пост прояснил некоторые вопросы, которые могли вчера у вас возникнуть.

Stay compatible. Stay cool.

#design #hardcore #cppcore
Что не нарушает ABI класса?

В комментах к предыдущему посту @MayerArtur удачно ванганул тему поста, который я писал в момент публикации его коммента. Поэтому этот пост обязан был выйти сегодня 😁. Вчера мы поговорили о том, что делать нельзя, если мы хотим сохранить стабильный ABI. Сегодня коротко пройдемся по тому, что делать можно. Завершим, так сказать, тему с ABI, чтобы картинка полная у вас была. Тут будет все в перемешку: и для хедеров, и для файлов реализации. Поехали:

Изменять реализацию метода. Довольно очевидно. Если не трогать сигнатуру, то внутри можно хоть хороводы водить с гусями, ничего для внешнего наблюдателя не изменится.

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

Добавлять новые конструкторы. Same thing. Ничто не помешает создать объект старым способом.

Добавлять новые енамы в класс.

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

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

Добавлять новые классы и функции в файл. Obviously.

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

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

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

Stay compatible. Stay cool.

#design #hardcore #cppcore
Сравниваем циклы

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

Берем довольно солидный массивчик на 1кк элементов и в циклах будем просто инкрементировать его элементы. Если вам 1кк кажется небольшим числом и результаты будут неточными, вы правы. Результаты в любом случае будут неточные, потому что моя тачка не заточена под перформанс тестирование. Однако флуктуации можно убрать: надо запустить изменение времени много-много раз и затем усреднить результаты. При достаточном количестве запусков, результатам можно будет верить. Я выбрал его равным 100 000. Просто имперически. Не хотел ждать больше 10 мин выполнения кода)

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

Шо по цифрам. В целом, все довольно ожидаемо. С++-like циклы прикурили у старых-добрых Си-style циклов. За комфорт, лаконичность и объектно-ориентированность приходится платить самым дорогим, что у программиста есть - процессорными клоками. Однако не совсем ожидаемо, что разница будет ~50%. Эт довольно много, поэтому все мы на заметочку себе взяли.

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

Ну и соболезнования для for_each. Им как бы и так никто не пользуется, еще и здесь унизили.

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

Measure your performance. Stay cool.

#performance #fun #goodoldc
Оптимизации компилятора

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

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

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

Но я не дал компилятору проявить себя во всей красе. Умные люди в комментариях сразу указали на эту проблему. Код компилировался без оптимизаций. И тот пост был подводкой к теме оптимизаций компилятора и как они могут аффектить наш код. Просто так рассказать про это было бы не очень интересно. А так чуть ли не скандал разразился и вы сильнее вовлеклись в тему😆. А я не устаю убеждаться, что в нашем коммьюнити много крутых и внимательных специалистов с критическим мышлением)

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

Существует дохренальен флагов оптимизации, но сегодня мы обратим внимание на группу флагов с префиксом -О. -О0, -О1, -О2, -О3. Это такие удобные верхнеуровневые рычажки, дергая которые вы включаете целый набор оптимизаций. Пока не будем углубляться из чего он состоит. Важно знать, что -О0 - дефолтный флаг(нет оптимизаций), и что чем больше чиселка при букве О, тем больше компилятор изменяет ваш код, чтобы он работал быстрее. Не факт, что у него получится что-то ускорить, но в среднем выигрыш будет. Какого характера может быть выигрыш?

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

#optimization #compiler #goodoldc #performance
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
Скрытые обсёры. Remove-erase idiom

В этом посте я рассказал про то, что такое remove-erase идиома. И начал я этот пост с объяснения эффективного способа удаления элементов из неупорядоченного массива. Этот алгоритм все еще прекрасный и волшебный, эффективнее него ничего нет в этом случае. Однако я сказал, что для упорядоченного массива нет такого эффективного способа решения проблемы удаления элементов. Под эффективным алгоритмом в этом случае я понимаю алгоритм с линейным временем работы.

Мало того, из поста как бы логично вытекает, что алгоритм std::remove работает так же на свопах элементов. Это конечно неверно. Как и высказывание из предыдущего абзаца. Спасибо админу Денису, что указал на мой затупок.

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

Откуда у меня в голове это было? Как-то раз на собесе мне задали задачку, что надо эффективно удалить нужные элементы из неупорядоченного массива. Я, как образцово-показательный кандидат, рассказал тот алгоритм со свопами и запрогал его. Тут мне прилетает вопрос. А нельзя ли как-нибудь элегантнее решить эту задачу, а то букав слишком много? Из каких-то глубочайших глубин(на тот момент) моего сознания я достал remove-erase идиому и мне засчитали ответ на вопрос верным. И я сложил 2 и 2 и объединил у себя в голове алгоритм со свопами и remove-erase идиому. Я не оправдываюсь, что во всем виноват интервьюер. Просто говорю, как могут ложные утверждения в голове появляться. Как говорил великий: "для кого-то, наверное, это будет оправданием, а я просто объясняю".

Так и что. Алгоритм std::remove действительно эффективно удаляет элементы из массива и при этом сохраняет относительный порядок оставшихся элементов. Делает он это, конечно, не так элегантно, как способ со свопами, но тоже за линейное время.

И я даже записал мой первый видос(!), в котором я объясняю то, как на самом деле работает std::remove. И даже выложил его на ютуб! Вот ссылочка на него жмак

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

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

Критика, любые комментарии - все приветствуется. Буду рад дельным советам.

Stay self-critique. Stay cool.
Зачем для Remove-Erase идиомы нужны 2 алгоритма?

Удалить из вектора элементы по значению или подходящие под какой-то шаблон не получится напрямую через API вектора. Ну точнее получится, просто вы не хотите, чтобы получалось именно так)
Дело в том, что метод erase у вектора действительно удаляет элемент или рэндж элементов. Но как он это делает?

Вектор - массив смежных ячеек памяти, в которых находятся объекты. То есть один элемент заканчивается и начинается новый. Представим, что мы хотим удалить объект где-нибудь в середине массива. Если мы просто вызовем деструктор этого объекта(отдельную ячейку памяти вернуть системе мы не можем), то тогда у нас нарушится этот инвариант вектора, что все элементы идут друг за другом. И это чисто идейная проблема. А на практике это место в памяти еще и можно было бы также итерпретировать, как живой объект, и работать с ним. Потому что в векторе нет механизма запоминания данных об удаленных ячейках. А если удалять рэндж, то будет целая область пустующая.

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

Хорошо. Лезем в cpp-reference и находим там алгоритмы std::remove и std:remove_if. Они принимают рендж начало-конец, ищут там конкретное значение или проверяют предикат на верность и удаляют найденные элементы. Вот что там написано про сложность:
Given N as std::distance(first, last)

1,2) exactly N comparisons with value using operator==.

3,4) exactly N applications of the predicate p.

Сложность удаления найденных элементов - линейная. Ну отлично. Х*як и в рабочий код. Тесты валятся, код работает не так как ожидалось. После применения алгоритма на самом деле ничего не удалилось. Элементов столько же, только они в каком-то странном порядке. Почему?

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

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

myVec.erase(std::remove(myVec.begin(), myVec.end(), value), myVec.end());

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

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

Stay based. Stay cool.

#cppcore #STL #algorithms #datastructures
Флаги контроля за встраиванием

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

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

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

-finline-small-functions - разрешает встраивать функции в код их коллера, когда их тело меньше, чем тот код, который генерируется для вызова этих функций. Тогда размер всей программы будет меньше. У компилятора там есть свои эвристики, по которым он принимает решение, достаточно ли маленькая определенная функция. В этом случае компилятор может применить инлайнинг ко всем функциям, даже к тем, которые не помечены ключевым словом inline. Применяется на уровнях оптимизации -O2, -O3, -Os.

-finline-functions - разрешает рассматривать вообще все функции, как кандидатов на встраивание, даже если они не помечены как inline. Опять же компилятор у нас самостоятельный дядя и сам решает, когда и что встроить. Применяется на уровнях оптимизации -O2, -O3, -Os.
Если все вызовы определенной функции встроены и она помечена static, то для нее вообще не генерируется ассемблер.

-findirect-inlining - разрешает встаивать также непрямые вызовы функции, например через указатель на функцию. Опция имеет смысл только, когда подключена хотя бы одна из двух предыдущих опций. Применяется на уровнях оптимизации -O2, -O3, -Os.

-finline-functions-called-once - разрешает рассматривать для встраивания все статические функции, которые лишь однажды вызываются, даже если они не помечены как inline. Если инлайнинг удался, то для функции не генерируется ассемблер. Применяется почти на уровнях оптимизации -O1, -O2, -O3, -Os, но не для -Og.

-finline-limit=n - по дефолту gcc ограничивает размер функций, которые могут быть встроены. Этот флаг позволяет грубо контролировать этот предел. n тут - это размер функции, измеряемый в псевдоинструкциях(знать бы еще что это такое).

-fkeep-inline-functions - оставляет ассемблер для всех встроенных функций. Даже для статических и встроенных во все свои вызовы.

-fpartial-inlining - разрешает встраивание частей функции. Это довольно агрессивная и опасная оптимизация, потому что не совсем понятно, как именно компилятор разбивает функцию на части и решает, какие из них встаивать. Опция имеет смысл только при включенных -finline-functions или -finline-small-functions. Применяется на уровнях оптимизации -O2, -O3, -Os.

Это все основные флаги. Есть еще несколько, но они сложны в описании и понимании, поэтому не буду их упоминать. А в этом списке вроде все логично, понятно и практически применимо.

Optimize your life. Stay cool.

#compiler #optimization #performance
auto в C vs auto в C++

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

Разберемся для начала, что оно значит в языке С. auto - один из storage-class specifiers, наряду с register, static и extern. Эти спецификаторы, которыми маркируются переменные и функции, определяют время жизни сущности и вид ее связывания. auto и register к тому же применимы только для переменных. Чтобы было понимание, приведу пример. static говорит, что переменная имеет статическое время жизни и внутреннее связывание. А auto говорит, что переменная имеет автоматическое время жизни и для отсутствует связывание. Первая часть предложения значит, что переменная аллоцируется на стеке и удаляется при выходе из скоупа. Вторая часть что переменная должна быть видна только в своем скоупе и никому другому она не нужна. Кого-то мне это напоминает....

Если подумать 3.35 секунды над этими вещами, то становится понятно, что все локальные переменные функции неявно помечаются как auto. То есть в этом ключевом слове нету особого смысла и никто им вообще не пользуется.

В таком виде оно перекочевало и в С++. Но разработчики 11-го стандарта подумали и решили поэксплуатировать бессмысленность и никомуненужность этого keyword. Так как им никто не пользуется, можно без опасений дать ему новое значение и не бояться гнева разъяренных разработчиков, пострадавших от отсутствия обратной совместимости.

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

Вот такие вот различия. Хотя в новом стандарте C23 auto теперь тоже может использоваться для вывода типов. Это не отменяет его роли в виде storage-class спецификатора, но вводит новый смысл в его использование.

Круто видеть, как два довольно древних языка развиваются и исправляют свои недочеты.

Fix your flaws. Stay cool.
Константный объект

Короткий будет пост, однако такие вещи важно понимать.

Если вы делаете объект константным, то и все его поля становятся константными автоматически. Это вроде довольно очевидно. Объект состоит из полей и константный объект - тот, у которого нельзя изменять его поля. Обычно это обеспечивается через закрытый доступ к полям и константный интерфейс. Вам просто компилятор не даст вызывать неконстантные методы. Но константные методы не просто помечены const и делают, что хотят. Компилятор должен удостовериться, что и в них поля никак не изменяют. А это можно реализовать, только пометив все члены класса как const. И сделать это рекурсивно по всему дереву вложенности.

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

Поэтому даже если мы имеем прямой доступ к полям объекта(как на скрине), то у нас не получится вызвать неконстантные методы этого поля. Компилятор скажет, что вы пытаетесь снять квалификатор, и не даст вам этого сделать. А вот константные - пожалуйста, вообще без вопросов.

Тоже самое кстати относится к volatile объектам по тем же причинам.

Stay safe. Stay cool.
⚠️ Предупреждение! ⚠️ Мы написали серию постов, которая готовилась на протяжении месяца. Эта тема является не столько сложной, сколько запутанной. Мы хотели, чтобы получился понятный и связанный материал. Посты будут длинные, местами очень душные, но они того стоят.

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

Зачем это нужно нам? Это даст доступ к новому контенту, где мы можем сослаться на подготовленную базу. Лучше подушить нам друг друга сейчас, чем потом постоянно затыкать пробелы. Призываем в комментариях писать то, что ВАМ непонятно! К шарящим просьба помочь нуждающимся 😉

План постов:
1) Категории выражений: lvalue и rvalue
2) CV-специфицированные значения
3) Категории выражений: xvalue
4) Универсальные ссылки
5) Идеальная передача
6) Исключения в перемещающем конструкторе
7) Оптимизации RVO / NRVO

Весь материал статей собран в одном документе: тут
Категории выражений

Итак, открываем серию постов, посвященную внутренним сущностям компилятора 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
Именование сущностей

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

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

Поэтому должны знать и понимать, что некоторые конструкции языка выражают намерения и описывают происходящее лучше, чем другие. Например, использование пользовательских классов значительно повышают читаемость куска кода по сравнению с тривиальными типами. Потому что они отвечают на вопрос "Зачем?", а не "Как?". И скрывают детали реализации. Когда мы читаем новый код нам нужно уловить смысл происходящего, причины и цели. Лишняя информация будет только шумом, препятствующим пониманию. А когда это верхнеуровневое понимание уже есть, дальше можно уже в детали реализации погружаться.

draw_line(int, int, int, int);

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

draw_line(Point, Point);

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

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

Stay above details. Stay cool.
Категория выражений 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
Идиома Remove-Erase устарела?

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

Точнее будет не нужно, после того, как ваши проекты полностью перейдут на С++20.

Дело в том, что этот релиз подарил нам 2 прекрасные шаблонные функции: std::erase и std::erase_if. Чем они занимаются в контексте идиомы? А занимаются они ровно тем же, только намного красивее. Если раньше нам приходилось использовать 2 алгоритма, чтобы удалить нужные элементы из вектора, то здесь нужна всего одна функция.

std::vector<int> myVec = {1, 2, 3, 4, 5};
std::erase(myVec, 2);

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

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

Stay updated. Stay cool.

#cpp20 #STL #algorithms
Постинкремент vs преинкремент

В С++ есть замечательные операторы инкремента(например вот в питоне их нет). Преинкремент увеличивает значение числа на единицу и возвращает ссылку на него. А постинкремент по идее создает временную переменную равную текущему значению числа, увеличивает число на единицу и возвращает по значению временную переменную. Поэтому кстати результат преинкремента - lvalue(возвращается как бы rvalue, но потом приводится к lvalue, потому что ссылка), то есть его можно использовать слева от знака равно, а постинкремента - rvalue, и его уже нельзя использовать слева от равно. Последнее верно в любом случае, это семантика языка. Но вот что насчет реализации?

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

Возьмем простенький пример:

int main()
{
for ( int i = 0; i < 10; ++i) {}
return 0;
}

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

И реально, мозгов хватает.

movl $0, -4(%rbp)
.L3:
cmpl $9, -4(%rbp)
jg .L2
addl $1, -4(%rbp)
jmp .L3
.L2:
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret

Такой ассемблер компилятор генерирует о обоих случаях. И это даже без оптимизаций!! С оптимизациями код был бы пустым, потому что мы ничего полезного не делаем. Но я проверял для более сложных случаев, когда код цикла реально генерировался, и с оптимизациями. Все одинаково.
Кладем нолик в память для i, сравниваем его с девяткой, если девятка больше или равна i, то прибавляем единичку и прыгаем обратно в цикл. Если девятка меньше чем i, то прыгаем на выход из main.

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

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

Stay relaxed. Stay cool.
500

Поздравляю всех причастных с очередным достижением на нашем канале!🎉🎊🎁💥

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

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

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

Всем замечательного дня!!