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

По всем вопросам (+ реклама) @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Зачем для 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
10🔥6👍4
Флаги контроля за встраиванием

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

Оговорюсь, что буду вещать про флаги 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
12👍9🔥3🎄2
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.
35👍21🔥71
Константный объект

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

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

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

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

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

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

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

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

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

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

Итак, открываем серию постов, посвященную внутренним сущностям компилятора 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
🔥22👍13101🆒1
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
👍22🔥108😁3
Именование сущностей

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

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

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

draw_line(int, int, int, int);

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

draw_line(Point, Point);

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

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

Stay above details. Stay cool.
👍21🔥63
Категория выражений 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
🔥16👍85
Идиома 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
👍16🔥93
Постинкремент 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.
14👍12🔥12🥰2
500

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

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

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

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

Всем замечательного дня!!
🔥35🎉23👍73❤‍🔥1👨‍💻1
Универсальные ссылки

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

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

Тип 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
14👍10🔥72
Еще про именование сущностей

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

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

Алиасинг типов, введенный в С++11, дает нам удобный механизм определения синонимов типов. Это как typedef, только на стероидах. Не будем сейчас говорить о их различиях. Нам важна суть. Мы можем написать:

using ResponseFrequencyByInstance = std::unordered_map<instance_id, size_t>;

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

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

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

Stay helpful. Stay cool.

#cpp11 #design #goodpractice
🔥12👍86
Идеальная передача — 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
👍17🔥103
Преинкремент vs постинкремент

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

Возьмем опять простенькую программу:

int main()
{
int i = 0;
int a = ++i; //i++;
return 0;
}

В первом случае я буду использовать преинкремент, во втором - постинкремент. Компилировать буду с g++ на x86-64 и без оптимизаций, потому что для такого примера просто не сгенерируется никакого кода, так как ничего полезного мы не делаем. Ну а на чуть более сложных примерах, компилятор понимает, что мы хотим сделать и просто засунет уже готовые числа по нужным адресам. Хочу продемонстрировать такую базовую хрестоматийную версию, о которой пишут в книжках.

Ассемблер для первой версии:
movl $0, -8(%rbp)
addl $1, -8(%rbp)
movl -8(%rbp), %eax
movl %eax, -4(%rbp)

Я сознательно опускаю всю обвязку и тут только суть. Кладем нолик в память со сдвигом 8 от rbp(этой ячейке памяти соответствует переменная i), добавляем к нему единичку, и через eax кладем это значение во вторую локальную переменную a со сдвигом 4 от rbp. eax - регистр-аккумулятор, в нем сохраняются промежуточные результаты арифметических вычислений, поэтому операция инкремента проходит через него. Интересно, что переменная a находится ближе к base pointer'у, хотя она объявлена позже. Это просто следствие того, что компилятор сам выбирает, как ему удобнее расположить локальные переменные на стеке и на это никак нельзя повлиять.

Теперь посмотрим на код постинкремента:
movl $0, -8(%rbp)
movl -8(%rbp), %eax
leal 1(%rax), %edx
movl %edx, -8(%rbp)
movl %eax, -4(%rbp)

Поменялось немногое, но различия уже заметны. Инструкцией leal мы складываем единичку с младшими 32-м битами в регистре rax(кто знает, почему не eax, напишите в комментах) и кладем это значение в edx, не трогая eax. Получается, что в edx создержится инкремент, а в eax - старое значение. Ну и дальше раскидываем их по правильным адресам на стеке. Обратите внимание, что в этом случае мы используем дополнительную память, а именно регистр edx, который не фигурировал в прошлом примере. Именно про это все говорят, когда объясняют, что постинкремент использует дополнительное копирование. Вот вам наглядное представление, как это утверждение ложится на уровень машинного кода.

Ставьте лайк, если нравятся такие низкоуровневые штуки и их на канале будет больше)

Stay hardwared. Stay cool

#hardcore #cppcore
27👍13🔥12
Исключения в перемещающем конструкторе

Продолжаем серию постов. Как вы могли заметить, во всех примерах с перемещающим конструктором был поставлен спецификатор 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
🔥186👍6❤‍🔥1
Инициализация вектора

Под вот этим постом в комментах завелась небольшая дискуссия по поводу целесообразности использования круглых скобок для инициализации объектов. Мол универсальная инициализация предпочтительнее и от круглых скобок можно вообще отказаться. На канале уже есть пост про то, что с вектором универсальную инициализацию использовать не получится из-за того, что у вектора есть конструктор от initializer_list и из-за этого вместо того, чтобы передавать объекты из {} в конструктор, компилятор рассматривает {} и все внутри них как один аргумент - initializer_list. И появляются приколы, что хочешь создать массив на 100 элементов, заполненных нулями, а получаешь массив на 2 элемента: 100 и 0.

Однако был выдвинут интересный вопрос. Что эффективнее: создавать массив и инициализировать его в конструкторе через круглые скобки или дефолтно создать массив, а потом использовать resize и std::fill. Сегодня мы это и проверим.

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

Снова наклепал простенький бенчмарк. Скомпилировал это дело 11-м гцц с 20-ми плюсами и O2 оптимизациями. Результаты можно увидеть на картинке к посту.

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

В целом, моя гипотеза подтвердилась. Создание массива чисел через круглые скобки быстрее на 20-30%, чем через последовательность конструктор-ресайз-филл. Но это с чиселками. Со строками последовательный вариант проигрывает уже почти в 2 раза. Хз, с чем это связано. Видимо как раз в этих самых межвызовных оптимизациях. Кто очень хорошо за асемблер шарит, наверное может подсказать причину.

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

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

Measure your performance. Stay cool.

#compiler #optimization #cpp11
🔥15👍74
shared_ptr и потокобезопасность

Использование шареного указателя для менеджмента ресурсов в многопоточных приложениях де-факто - стандарт. Ничего другого адекватного нет для совместного использования ресурсов несколькими потоками одновременно. Есть конечно глобальные объекты, но это практически всегда зло и не очень хочется с этим связываться. И возникает вопрос - а безопасно ли использоваться std::shared_ptr в многопоточном контексте?

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

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

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

Так что вызов изменяющих методов объектов, для которых не позаботились о их безопасности, в разных тредах - гарантированные трудноотловмые многопоточные проблемы. Какие? Да вообще любые. Пусть вы храните какую-нибудь мапу и передаете ее везде в виде шаред поинтера. Как только к эту мапу захочет записать какой-нибудь поток, начнет это делать и через писечную долю секунды придет читающий поток, то он увидит данные в неконсистентном состоянии. Только что на ваших глазах произошла так называемая гонка данных. И это самая тривиальная проблема. Баги могут быть намного более изощренными.

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

Stay safe. Stay cool.

#concurrency #cpp11
🔥18👍65👏2
Самая программистская математическая задачка

Есть одна задачка, которая вскружила мне голову элегантностью своего решения. Сформулирована она как обычная задачка на логику, но по сути своей очень программистская. Для людей с очень «низкоуровневым мышлением». Поэтому и решил с вами ей поделиться. Кажется плюсовый канал - подходящее место для таких задачек. Может я преувеличиваю, но я художник, я так чувствую.

Собсна формулировка. Жил был король. Хорошо король правил своей страной, богатая она была. И король соответственно тоже жил в большой роскоши. Появился у этого короля завистник среди придворной интеллигенции и захотел он убить короля. Думал про разные способы убийства и вспомнил одну деталь. Король очень любит вино и в погребе дворца есть 1000 бутылок его самого любимого вина, которое он пьет каждый день. И решил завистник отравить вино. Послал вместо себя убийцу в погреб, чтобы он отравил бутылки. Однако убийцу быстро нашли и поймали до того, как он отравил все бутылки. По правде говоря, он успел только одну из них отравить.

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

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

Чтобы убить живое существо, яду нужно примерно 24 часа.

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

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

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

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

Solve problems. Stay cool.

#fun
👍13🔥7😁4🤔2