ch
Feedback
Грокаем C++

Грокаем C++

前往频道在 Telegram

Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов. По всем вопросам (+ реклама) @ninjatelegramm Менеджер: @Spiral_Yuri Реклама: https://telega.in/c/grokaemcpp Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat

显示更多
9 355
订阅者
+224 小时
-257
-1230
吸引订阅者
六月 '26
六月 '26
+92
在0个频道中
五月 '26
+153
在0个频道中
Get PRO
四月 '26
+149
在0个频道中
Get PRO
三月 '26
+95
在0个频道中
Get PRO
二月 '26
+126
在0个频道中
Get PRO
一月 '26
+128
在0个频道中
Get PRO
十二月 '25
+143
在0个频道中
Get PRO
十一月 '25
+163
在0个频道中
Get PRO
十月 '25
+113
在1个频道中
Get PRO
九月 '25
+119
在1个频道中
Get PRO
八月 '25
+228
在1个频道中
Get PRO
七月 '25
+578
在4个频道中
Get PRO
六月 '25
+724
在2个频道中
Get PRO
五月 '25
+307
在2个频道中
Get PRO
四月 '25
+148
在1个频道中
Get PRO
三月 '25
+246
在1个频道中
Get PRO
二月 '25
+487
在6个频道中
Get PRO
一月 '25
+233
在1个频道中
Get PRO
十二月 '24
+293
在0个频道中
Get PRO
十一月 '24
+215
在0个频道中
Get PRO
十月 '24
+1 593
在18个频道中
Get PRO
九月 '24
+629
在5个频道中
Get PRO
八月 '24
+490
在5个频道中
Get PRO
七月 '24
+206
在2个频道中
Get PRO
六月 '24
+1 383
在19个频道中
Get PRO
五月 '24
+833
在7个频道中
Get PRO
四月 '24
+1 851
在11个频道中
Get PRO
三月 '24
+350
在0个频道中
Get PRO
二月 '24
+691
在0个频道中
日期
订阅者增长
提及
频道
25 六月+1
24 六月+4
23 六月0
22 六月+3
21 六月0
20 六月+1
19 六月0
18 六月+6
17 六月+1
16 六月+3
15 六月+5
14 六月+1
13 六月+3
12 六月+4
11 六月+11
10 六月+4
09 六月+2
08 六月+5
07 六月+3
06 六月+8
05 六月+3
04 六月+12
03 六月+4
02 六月+2
01 六月+6
频道帖子
​​Дефолтные конструкторы. Такие разные #опытным Существует 3 вида дефолтных конструкторов и важно понимать, в чем у них различия, чтобы разрешать нужную функциональность. 1️⃣ Тривиальный дефолтный конструктор По сути задача дефолтного конструктора - вызывать у своих полей и подклассов дефолтный конструктор. А что если все поля класса имеют тривиальные типы?
struct Foo {
    int i;
    double d;
    char c;
};
На самом деле у всех тривиальных базовых типов в С++ есть конструктор по-умолчанию. Но он тривиальный. То есть ничего вообще не делает и никак не инициализирует объект. И это свойство может передастся конструктору Foo. Если для такого типа дефолтный конструктор будет сгенерирован компилятором, то он тоже ничего делать не будет. Конструктор по-умолчанию называется тривиальным, если: 👉🏿 У класса нет виртуальных методов. 👉🏿 Все базы класса имеют тривиальные конструкторы по-умолчанию. 👉🏿 Все поля класса имеют тривиальные конструкторы по-умолчанию. Все типы, которые совместимы с С, обладают этим свойством. Если вам нужна такая совместимость, тривиальный конструктор по-умолчанию - это ваш бро. 2️⃣ Нетривиальный дефолтный конструктор, неявно сгенерированный компилятором Если ваш класс содержит поле, у которого есть нетривиальный конструктор, то вам скорее всего и не нужна С-совместимость. Но вы можете приобрести кое-что другое. Предоставив компилятору честь неявно сгенерировать конструктор, вы разрешаете инициализировать объект с помощью агрегатной инициализации:
struct Foo {
    Foo() = default;
    int i;
    std::string s;
    std::vector<int> v;
};

Foo f = {42, "Hello World", {1, 2, 3}};
можете даже designated initialization воспользоваться:
Foo f = {.i = 42, .s = "Hello World", .v = {1, 2, 3}};
Там есть конечно еще несколько требований, но опустим их, чтобы не сбивать фокус. 3️⃣ Нетривиальный пользовательский конструктор по-умолчанию Как только вы сами определили дефолтный конструктор, то вы сразу же попали в эту категорию. И лишились преимуществ, описанных выше. Даже если вы определили пустой конструктор, он все равно считается кастомным:
struct Foo {
    Foo() {};
    int i;
    std::string s;
    std::vector<int> v;
};

Foo f = {42, "Hello World", {1, 2, 3}}; // ERROR
Да, с инициализацией у С++ довольно сложные отношения, но много чего идет от наследия С и обратной совместимости. Don't be trivial. Stay cool. #cppcore

2
Дефолтный конструктор. Введение #новичкам Описание корректного интерфейса создания объекта - важная часть проектирования класса. Поэтому надо понимать нюансики работы с конструкторами, чтобы все правильно организовать. Ну и конечно самый базовый и, потенциально, самый сложный с точки зрения языка - конструктор по-умолчанию. class NoConstructor { int total; public: void accumulate (int x) { total += x; } }; Example ex; Казалось бы, у Example вообще не определено ни одного конструктора. Но тем не менее объект успешно создался. Как так? Дефолтный конструктор умеет за вас генерировать сам компилятор. А почему бы и нет? Смотря со стороны даже вполне логично и понятно, что он должен делать: вызывать конструкторы по умолчанию для всех нестатических членов и базовых классов в порядке объявления. Если мне от дефолтного конструктора нужно только это, то я могу просто положиться на компилятор. Таким образом конструктор по-умолчанию входит в число специальных методов классов, которые компилятор сам умеет генерить. Однако, не все так просто. Как только вы определите хотя бы один другой конструктор, компилятор перестанет генерить дефолтный: class ParametrizedConstructor { int total; public: Example2(int initial_value) : total(initial_value) { }; void accumulate (int x) { total += x; }; }; Example2 ex (100); // ok Example2 ex1; // error: no default constructor Оно и понятно: если вы не определяли никакой конструктор, значит вы довольны дефолтным поведением. Но как только вы сами определили конструктор, вы сказали компилятору, что дефолтное поведение вам не подходит и вы берете ответственность за способы создания объектов этого класса. И компилятор не смеет перечить вашей задумке. Очень может быть, что вы хотите, чтобы Example2 создавался только через параметрический конструктор и больше никак. По сути, именно это и прописано сейчас в классе. Если бы компилятор неявно добавил конструктор по-умолчанию, то это нарушило бы контракт вашего класса. Если вы все-таки хотите, чтобы у вас была возможность создать объект по-умолчанию, то вам явно нужно добавить конструктор без аргументов: class ParametrizedAndDefaultConstructor { int total; public: Example3() = default; // или так Example3() {} // или так Example3(int initial_value) : total(initial_value) { }; void accumulate (int x) { total += x; }; }; Example3 ex(100); // ok Example3 ex1; // ok Example3() = default; - вы явно определяете конструктор по-умолчанию, но так же явно просите компилятор о том, чтобы его поведение было как если бы сам компилятор его генерировал. Example3() {/something/} - это вы уже самостоятельно определяете, какую дополнительную логику должен иметь этот конструктор. Он делает все то же самое, что и тривиальный, только вдобавок выполняется еще и то, что вы указали внутри фигурных скобок. Такой конструктор называется уже нетривиальным, даже если его тело в итоге оказалось пустым. О том, какую роль 50 оттенков конструктора по-умолчанию при обращении с объектами классов, мы поговорим в следующем посте. Don't be trivial. Stay cool. #cppcore
1 296
3
Магия Lovable: как создавать готовые интерфейсы с помощью одного запроса. Бесплатный урок курса «Вайб-кодинг: создание цифров
Магия Lovable: как создавать готовые интерфейсы с помощью одного запроса. Бесплатный урок курса «Вайб-кодинг: создание цифровых продуктов с ИИ» Lovable может за минуты собрать экран, который выглядит как почти готовый интерфейс. Но результат зависит не от «магии нейросети», а от того, насколько точно вы ставите задачу. Один расплывчатый запрос даст случайный макет, а правильно собранный системный промпт — понятную структуру, единый стиль и экран, который уже можно показывать команде, заказчику или использовать для проверки идеи. На открытом уроке 2 июля в 20:00 разберём, как формулировать задачи для Lovable, чтобы получать предсказуемый результат с первой попытки. Поговорим о структуре системного промпта, ключевых словах, которые помогают превратить текст в качественный интерфейс, и способах доработки результата через встроенный редактор и повторные запросы. Отдельно обсудим, как управлять компонентами, просить нейросеть переиспользовать элементы и сохранять единый визуальный стиль. Урок не для тех, кто ждёт, что Lovable «сам всё поймёт», не готов уточнять задачу и хочет получать качественный интерфейс без структуры, контекста и итераций. 👉 Записаться: https://otus.pw/nU5o/ Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576
1 460
4
​​Конструктор и инвариант #новичкам У нас есть 2 варианта, как мы можем наполнять объект данными. 1️⃣ Делаем все поля публичными, не определяем ни одного конструктор и используем агрегатную инициализацию struct Order { std::string client_id; std::string item_id; float amount; }; Example ex = {"123", "1234", 42.0}; 2️⃣ Делаем конструктор и заполняем приватные поля в нем class Clock { int hour, minute; public: Clock(int h, int m) : hour(h), minute(m) { ... } }; auto cl = Clock(12, 34) Как понять, какой вариант выбрать, когда в следующий раз придется писать новый класс? Будем разбираться Для того, чтобы агрегатная инциализация была корректным способом создания объекта, должны быть выполнены некоторые условия: 👉🏿 Поля должны быть независимы друг от друга. 👉🏿 Поля должны иметь возможность представлять собой весь спектр значений своего типа. 👉🏿 Нет дополнительной логики, которая срабатывает на каждое создание объекта. Эти 3 пункта гарантируют, что наш класс - это просто набор каких-то значений. Мы можем собрать этот набор частично, поменять содержимое по середине пути и ничего плохого не произойдет. В противном же случае в вашем классе есть инвариант, который важно сохранять для каждого объекта. Например, в объекте типа даты, который создается из строки, нужно проверять, валидная ли дата передана. В объекте типа математического интервала левый конец должен быть меньше либо равен правому. Или при создании какого-то объекта мы накручиваем счетчик из метрики. И вот как раз для того, чтобы вся логика проверки и обеспечения инварианта объекта была внутри кода класса, а не в клиентском коде, нужно определять конструктор. Так детали реализации будут инкапсулированы внутрь класса. Это позволит абстрагироваться от них в клиентском коде и даже если логика инварианта изменится, то это никак не затронет внешний код. class BufferView { const char* data; size_t size; public: BufferView(const char* ptr, size_t len) : data(ptr), size(len) { if (size > 0 && data == nullptr) throw std::runtime_error("BufferView error: nonzero size while data is nullptr"); } }; Странно было бы на пользователя возлагать ответственность за проверку соответствия размера и содержимого буфера. Они ведь такие безответственные. Ну и в будущем, если мы захотим запретить вообще передавать в буффер nullptr, то это изменение коснется только кода класса. Don't give details to a client. Stay cool. #design #goodpractice
1 991
5
​​Процесс и поток. Side‑by‑side #новичкам Мы разобрали, что процесс — это контейнер ресурсов, а поток — единица выполнения. Теперь давайте поставим их рядом и посмотрим, кто во что горазд. Будет еще немного новой информации, но подробности оставим за скобками. 👉🏿 Суть Процесс - это окружения для выполнения программы. Процесс - само исполнение. 👉🏿 Память Каждый процесс имеет своё изолированное независимое виртуальное адресное пространство. В базе процесс не имеет доступа к данным другого процесса. Все потоки одного процесса разделяют его память (кучу, глобальные переменные, код) и могут легко обращаться к одним и тем же данным. 👉🏿 Изоляция Сбой одного процесса обычно не влияет на другие. ОС в базе запрещает одному процессу влезать в дела другого. Сбой же в одном потоке (например, segmentation fault) убивает весь процесс. Потоки одного процесса легко обмениваются информацией 👉🏿 Создание и переключение Процессы тяжело создавать(fork() копирует таблицы страниц, дескрипторы, структуры ядра) и переключать(требуется смена таблиц страниц) Потоки же создавать легче: нужен только новый стек и небольшой блок управления(нужный например для сохранения контекста). Переключаются потоки сменой контекста aka подменой нескольких регистров. 👉🏿 Взаимодействие Есть термин IPC - Inter Process Communication. Реализовывать межпроцессное взаимодействие можно с помощью пайпов, сокетов, shared memory, сигналов. Требует использования механизмов ядра. С потоками чуть легче, они имеют доступ к одной и той же памяти. Только чтобы не нарваться на гонку данных, нужна синхронизация через мьютексы, атомики, барьеры. 👉🏿 Ресурсы Процесс владеет всей своей памятью(кодом, кучей, сегментом глобальных данных), а также файловыми дескрипторами и переменными окружения. Потоки же имеют доступ ко всем этим ресурсам + у них есть персональные стеки и набор регистров. 👉🏿 ID У процесса есть PID, с помощью которого ОС идентифицирует процесс. У каждого потока также есть свои айдишники - TID, и они принадлежат одному PID. Как-то так. Надеюсь, что теперь у вас есть понимание, что вопрос о разнице между потоком и процессом почти эквивалентен вопросу о разнице головы и зубов. Это хоть и тесно связанные, но абсолютно разные понятия. See the difference. Stay cool. #OS
2 057
6
🤖Программа работает стабильно до тех пор, пока не появляется загадочный сбой, неожиданное завершение или утечка памяти. И че
🤖Программа работает стабильно до тех пор, пока не появляется загадочный сбой, неожиданное завершение или утечка памяти. И чем крупнее проект, тем дороже обходятся такие ошибки. 📆2 июля в 20:00 МСК на открытом уроке разберём одну из ключевых тем языка, от которой напрямую зависят надёжность и предсказуемость работы приложений. На практических примерах рассмотрим жизненный цикл объектов, создание и удаление данных, типичные ошибки при работе с памятью и способы поиска утечек. Поговорим о неочевидных случаях, которые встречаются даже в зрелых проектах. 🏁Урок проходит в преддверии старта курса «C++-разработчик». Регистрируйтесь, чтобы разобраться в фундаментальных механизмах языка, познакомиться с форматом обучения и задать вопросы эксперту: https://otus.pw/goTB/ Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576, www.otus.ru
1 882
7
​​Процесс и поток. Поток #новичкам Чтобы ввести термин "поток" надо очень верхнеуровнего понять, как вообще код исполняется на ядре. У нас есть 2 важных компоненты, без которых ничего не возможно. Это память и CPU. CPU скармливаются данные из памяти, он их пережевывает и выплевывает другие данные, которые также записываются в память. CPU нужны операнды и инструкция, что с ними делать. Начнем с инструкций У нас есть скомпилированная программа и она в том числе содержит непосредственно код программы. Грубо говоря, это полотно инструкций, которые одну за одной надо исполнить на CPU. Машинный код программы хранится в памяти процесса и для него нужен какой-то механизм исполнения. Чтобы отслеживать, какая инструкция будет выполняться следующей, нужен специальный Program Counter(PC) или счетчик инструкций. Процесс такой: CPU загружает инструкцию, на которую указывает PC, увеличивает счетчик, декодирует инструкцию, делает нужные преобразования над операндами, сохраняет результат и цикл повторяется снова. Теперь операнды Для операций процессор чаще всего берёт операнды из регистров общего назначения, куда они предварительно загружаются из памяти. Из какой? Из 3-х областей памяти: стека, кучи и сегмента глобальных данных. Куча и глобальные данные шарятся между потоками исполнения и принадлежат процессу. А вот как раз стек и связан с непосредственным обеспечением исполнения кода в моменте. Он нужен для работы функций. Каждый фрейм стека соответствует одной вызванной, но ещё не завершённой функции. Как только в коде вызывается функция, на стек кладется информация о ней. В основном это локальные переменные и адрес возврата. Так информация об исполнении всех вызванных в данный момент функциях остается в памяти. Есть 2 важных регистра, которые связаны со стеком. Это base pointer(указатель на начало блока самой последней вызванной функции) и stack pointer - указатель на вершину стека. По итогу информация из памяти(стека, кучи и тд) загружается в регистры общего назначения, откуда ею может пользоваться сам процессор. Вот это очень грубо то, как исполняется код на процессорах. Но этот процесс описывает исполнение одной программы. А в современных компьютерах есть многозадачность аля множество программ могут выполняться на одном ядре. Как этого достигают? На самом деле стек, значения счетчика инструкций и регистров полностью определяют состояние исполнения текущего кода. Оно называется контекстом. Если где-то сохранить это состояние, то можно загрузить PС и регистры из другого контекста и продолжить исполнение уже другой программы, а исполнение изначальной прервется. Потом, когда наступит нужный момент, исполнение изначальной программы продолжится также с помощью загрузки ее сохраненного состояния. Так вот по сути поток - это минимальная единица исполнения кода, которой ОС выделяет кванты времени для работы. Она владеет своим стеком и характеризуется конкретными значениями счетчика инструкций и регистрами, которые составляют контекст потока. Переключение между потоками выполняет планировщик ядра, сохраняя контекст одного потока и загружая другой. Поток в программе может быть один и может быть несколько. Все потоки шарят между собой общую кучу, сегмент глобальных данных, код программы и прочие ресурсы процесса типа дескрипторов. И да, стеки потоков одного процесса тоже располагаются в памяти этого процесса. Просто потоки не могут легальными способами получить доступ к стекам других потоков того же процесса, чтобы не нарушать инкапсуляцию данных. Поток пользуется данными либо из своего стека, либо из общей кучи, либо из общих глобальных объектов. Ну и совсем коротко о том, как явно в программе создать поток. В С++ это делается через объект std::thread. void hello() { std::cout << "Hello thread!\n"; } int main() { std::thread t(hello); t.join(); } Ядро выделяет новый стек для потока, инициализирует его контекст так, чтобы выполнение началось с функции hello. После этого поток попадает в очередь планировщика и получает свой квант времени. Share resources. Stay cool. #OS #concurrency
2 010
8
​​Процесс и поток. Процесс #новичкам Сегодня по-подробнее поговорим про процессы, зачем они нужны, какую роль играют и какой у них жизненный цикл. Но опять же в дебри лезть не будет, погрузимся только в важные моменты. Процессы, как и все в этом мире, появилось в результате эволюции ОС и их подходов к управлению программами. Главный вызов - организация мультизадачности. Хочется, чтобы на одном компьютере могли исполняться множество программ. Процесс - это абстракция ОС, предназначенная для удобного, безопасного и независимого исполнения кода различных программ на одном компьютере. Пожалуй, самое важное здесь - процесс нужен для изоляции исполнения одной программы от исполнения другой. Изоляция достигается несколькими механизмами, обсудим самые важные: ⚡️ Механизм виртуальной памяти. У каждого процесса свое независимое ни от кого виртуальное пространство адресов. Все операции с памятью через ОС. Так она контролирует, чтобы один процесс не мог всякими неблагонадежными способами заполучить информацию из других процессов. Если он все-таки попытается что-то подобное сделать, то получит сигнал SIGSEGV. У процессов конечно могут быть общие для доступа участки памяти(shared memory), но это делается явно и контролируемо. ⚡️Привилегии процессов. В современных ОС используется два уровня привилегии: режим ядра и пользовательский режим.  Код пользовательского процесса не может выполнять привилегированные инструкции и не имеет прямого доступа к аппаратуре. Системные вызовы — единственный канал для взаимодействия с ядром, и они строго контролируются. Грубо говоря, пользовательский процесс(а это все процессы прикладных приложений) физически не может сделать ничего плохого на уровне ОС и повлиять на работу других программ. Про изоляцию мы поняли, за счет нее обеспечивается безопасность железа и других программ. Но у процесса есть еще набор ресурсов, с помощью которых он взаимодействует с внешним миром: 1️⃣ PID - айдишник потока. Это уникальный номер, который ОС присваивает каждому процессу в момент его создания. PID используется для идентификации процесса: по нему можно отправить сигнал, получить информацию о процессе или управлять им. 2️⃣ Когда программе нужно открыть файл, установить сетевое соединение или создать объект синхронизации, она обращается к ОС. В ответ ОС выдаёт дескриптор — небольшой идентификатор, который становится «пропуском» для работы с этим ресурсом. Практически любой ресурс, принадлежащий процессу, может быть представлен дескриптором: файлы, сокеты, каналы, устройства, таймеры и тд. 3️⃣ Переменные окружения. Это именованные строковые значения, которые ОС передаёт каждому процессу при его запуске. Они описывают среду, в которой работает программа: где искать исполняемые файлы, где хранить временные данные. Секреты для приложения также часто передаются через переменные окружения. 4️⃣ Память. Хоть процесс распоряжается виртуальной памятью, она все равно маппится на физические адреса. В памяти процесса содержатся стеки потоков, куча, код самой программы. Хоть процессы и изолированы, но если в одном из них утекает память или дескрипторы, страдают от этого все. Теперь чуть-чуть-чуть про цикл жизни В Unix-подобных системах процесс создаётся системным вызовом fork(). Он копирует текущий процесс в новый и ему присваивается свой уникальный PID. Оба процесса продолжают выполнение с инструкции, следующей сразу после вызова fork(). При этом в родительском потоке возвращается pid ребенка, а в ребенке возвращается 0: int main() { pid_t pid = fork(); if (pid == 0) { printf("Child: PID=%d, parent=%d\n", getpid(), getppid()); } else if (pid > 0) { printf("Parent: PID=%d, child=%d\n", getpid(), pid); } else { perror("fork failed"); return 1; } return 0; } Процесс может завершиться самостоятельно, вызвав системный вызов exit(), или быть принудительно убитым сигналом (SIGKILL, SIGSEGV и др.). При завершении ядро освобождает все занятые ресурсы процесса и удаляет информацию о нем в своих структурах. #OS #goodoldc
2 223
9
Ищем Senior Rendering Engineer (C++) в команду 3D-карты 2ГИС Мы делаем карту более реалистичной: работаем над рельефом, дорог
Ищем Senior Rendering Engineer (C++) в команду 3D-карты 2ГИС Мы делаем карту более реалистичной: работаем над рельефом, дорогами, развязками, тоннелями, анимациями и графическими эффектами. Внутри — собственный 3D-движок на C+20 (500k строк кода), современные графические API (Vulkan, Metal, OpenGL), шейдеры, сложные алгоритмы и задачи производительности. Будет интересно, если вам нравится компьютерная графика, низкоуровневая разработка и создание продукта, который ежедневно используют миллионы людей. Удалённо из РФ или из офисов 2ГИС. ДМС, обучение, конференции и возможность напрямую влиять на развитие 3D-карты. Подробнее Другие инженерные инсайты от 2ГИС → в Telegram-канале RnD
1 731
10
​​Процесс и поток. Суть #новичкам Один из самых популярных вопросов на собесах "в чем отличия потоков и процессов?". Новичков он осаживает, потому что они изучали, как писать код, а не нюансы работы операционной системы и всех этих подкапотных дел. Плюс оба понятия как будто подразумевают какую-то движуху. Но какую? Погуляв по сети при подготовке к этой серии постов, я ужаснулся количеству путаницы, непонятицы, противоречивицы в "объяснениях" этой темы на русском языке. Надеюсь, что у меня получится лучше, поэтому буду рад фитбэку от новичков касательно понятности изложения. Сейчас не будем вдаваться в детали работы этих сущностей(это в будущем). Сейчас важно просто понять их суть и как они взаимодействуют между собой. Любая запущенная программа - это "процесс" с точки зрения ОС. По сути это некая сущность внутри компьютера, которая в себя инкапсулирует весь необходимый объем мероприятий, чтобы ваш бинарник мог в реальности что-то там рассчитывать. Но сам процесс ничего не считает. Он как оболочка или контейнер, внутри которого происходят расчеты. Само же исполнение кода(расчеты) реализуется потоками. Обратимся к аналогии для пущего понимания. Мы в ресторане, в котором есть кухня. Кухня, обеспечивающая едой сотни людей в день - это довольно большая и сложная хреновина, в которой много чего есть: само помещение, вентиляция, холодильники, печки, сковородки и тд. Все это нужно для того, чтобы вы поели том-ям. Но кухня сама по себе не работает. Нужны повара, которые по технологической карте из правильных ингредиентов будут готовить еду. Используя инструментарий кухни, повара производят еду. Так вот кухня - это процесс, а повара - потоки. Процесс нужен для обеспечения исполнения, а потоки - это и есть исполнение. На самом деле пожарить вам стейк или скрутить шаурму может и один человек. Он будет делать все сам, просто не такие большие объемы будут. Если нужно мастабироваться и разделять обязанности, то придется нанимать еще поваров. Давайте резюмируем и проговорим несколько важных вещей: 👉🏿 Процесс — это экземпляр выполняющейся программы. Это сущность, предоставляющая инструменты для исполнения кода. 👉🏿 Поток - само исполнение кода. Если где-то выполняются инструкции кода, значит там есть поток исполнения. 👉🏿 У каждого ресторана своя площадь, с другими заведениями он местом не делится. Так и у процесса своё адресное пространство — там он царь и бог, но к чужой памяти у него нет доступа . 👉🏿 Повара на кухне свободно общаются и делят оборудование. *Потоки одного процесса легко обмениваются данными, так как используют одну и ту же память и ресурсы 👉🏿 Если повар ошибётся и сломает холодильник — вся колбаса может протухнуть, клиенты будут тесно дружить следующие пару дней с белым другом и писать жалобы на ресторан, что может его вообще обанкротить. Но эта протухшая колбаса никак не повлияет на кафешку рядышком за углом. Потоки могут сделать что-то нехорошее и досрочно завершить работу процесса с ошибкой. Но это никак не повлияет на работу других процессов. Get to the essence. Stay cool. #OS
2 979
11
​​Loop unrolling. Compiler #новичкам В прошлых постах я часто писал, что компилятор сам умеет сносно разворачивать циклы. Но это конечно оптимизация и надо знать, как ее включать. Можно конечно и прагмами, но это уже специальные подсказки в коде. Но как заставить компилятор разворачивать циклы, не трогая сам код? Будем обсуждать все на примере gcc. Ну и для начала: базово без оптимизаций компилятор просто генерирует код слово в слово. int sum(std::span<int> ints) { int sum = 0; for (auto value : ints) sum += value; return sum; } В ассемблере, сгенеренном на -O0, здесь будут вызовы итераторов, честные проверки и тд Хоть какие-то циклы компилятор начинает разворачивать на -O2. И далеко не во всех случаях. Например для сниппета выше он очень хорошо упростит код цикла, но все еще развертки не будет. Но если спан будет фиксированного размера(да, такой есть), кратного 2-м или 4-м: int sum(std::span<int, 20> ints) { int sum = 0; for (auto value : ints) sum += value; return sum; } То компилятор развернет цикл c фактором 4 и дополнительно оптимизирует 4 сложения с использованием sse векторных инструкций и xmm регистров: sum(std::span<int, 20ul>): lea rax, [rdi+80] pxor xmm0, xmm0 .L2: movdqu xmm2, XMMWORD PTR [rdi] add rdi, 16 paddd xmm0, xmm2 cmp rax, rdi jne .L2 movdqa xmm1, xmm0 psrldq xmm1, 8 paddd xmm0, xmm1 movdqa xmm1, xmm0 psrldq xmm1, 4 paddd xmm0, xmm1 movd eax, xmm0 ret Однако, поменяв размер 20 на 19, то векторизация сломается. Итого: на О2 gcc разворачивает циклы с фиксированным, кратным 2-м, числом итераций. Тепер -O3. Здесь уже могут разворачиваться циклы с неизвестным числом итераций. Обязательно добавляется обработка хвоста и, при возможности, цикл векторизуется. Если число итераций известно, то цикл может быть полностью развернут. Вот примерчики на годболте. На этом уровне компилятор руководствуется сложными эвристиками, которые позволяют ему оценивать, будет ли в конкретном случае буст от развертки или нет. Ну это базовые флаги оптимизации. Есть и специальные, конкретно под развертку. -funroll-loops - позволяет разворачивать все циклы, количество итераций которых может быть определено во время компиляции. Если цикл маленький, он развернется полностью и цикла вообще не останется. Цитата из документации gcc: This option makes code larger, and may or may not make it run faster. -funroll-all-loops - разворачиваем все, что может быть развернуто, нас ничего не остановит. Цитата из доки: This usually makes programs run more slowly. В общем-то ничего нового: не нужно тупо делать вещи, которые в теории что-то делают быстрее. Профилируйте, возможно стоит просто положиться на компилятор и будет вам счастье. Don't follow the rules blindly. Stay cool. #compiler #optimization #performance
2 686
12
Когда ИИ-агент выходит за пределы экспериментов, одного «умного чата» становится мало. Чтобы агент был полезен в рабочей разр
Когда ИИ-агент выходит за пределы экспериментов, одного «умного чата» становится мало. Чтобы агент был полезен в рабочей разработке, ему нужны правила, доступ к инструментам, понятный контекст, проверка действий и безопасная обвязка. Иначе вместо ускорения команда получает непредсказуемость, лишние риски и дорогой хаос в контекстном окне. На открытом уроке 15 июня в 20:00 разберём, как устроены современные ИИ-агенты и их обвязка: правила, модули навыков и MCP — протокол подключения модели к внешним инструментам.  Поговорим, чем поведенческий слой агента отличается от слоя подключения, где искать готовые навыки, почему они стали популярны и как их устанавливать. Отдельно обсудим, как с помощью MCP дать агенту нужные инструменты, не перегружая контекст, а также как защищать агентов: схемы проверки, журналы аудита и типовые способы атак. Урок не для тех, кто хочет просто «подключить агента к проекту» без правил, контроля и понимания рисков. И не для тех, кто считает, что рабочая интеграция ИИ — это только написать хороший запрос. Регистрация: https://otus.pw/D8CC4/ Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576, www.otus.ru
1 549
13
Loop unrolling. Мануально #опытным Мы можем конечно и сами разворачивать циклы, но код будет повторяться и от этого потенциально много проблем будет. Ну хорошо, у нас есть все инструменты, чтобы явно не повторять код. Например вот так: template <typename F, std::size_t... Is> void repeat_unrolled_impl(F&& f, std::index_sequence<Is...>) { ((f(), void(Is)), ...); } template <std::size_t Iterations, typename F> void repeat_unrolled(F&& f) { repeat_unrolled_impl(std::forward<F>(f), std::make_index_sequence<Iterations>{}); } volatile int i = 0; int main() { repeat_unrolled<6>({ ++i; }); } Определяем парочку шаблонных функций, которые с помощью std::index_sequence и fold expression полностью изничтожают цикл и просто подряд вызывают функтор столько раз, сколько вы указали в шаблонном параметре repeat_unrolled. Асм можете посмотреть тут. Здесь конечно есть одна хитрость с volatile, чтобы компилятор не соптимизировал наши тривиальные вычисления, но тем не менее. Хотите частичную развертку? Да пожалуйста: template <typename F, std::size_t... Offsets> void call_with_offsets(F&& f, std::index_sequence<Offsets...>) { ((f(), void(Offsets)), ...); } template <std::size_t Iterations, std::size_t UnrollFactor, typename F> void partial_unroll(F&& f) { constexpr std::size_t full_blocks = Iterations / UnrollFactor; constexpr std::size_t remainder = Iterations % UnrollFactor; for (std::size_t block = 0; block < full_blocks; ++block) { call_with_offsets(std::forward<F>(f), std::make_index_sequence<UnrollFactor>{}); } for (std::size_t i = 0; i < remainder; ++i) { f(); } } volatile int counter = 0; int main() { partial_unroll<101, 4>({ ++counter; }); } Приемы используются те же, развертку тоже можете сами увидеть. Зачем этот пост? Да просто наткнулся на такой код при подготовке к этой серии статей. Удивился, что в нескольких источниках упоминается такой способ и решил поделиться. Не знаю на счет применимости такого кода, выглядит сомнительно и не во всех ситуациях подойдет. Но по крайней мере где-нибудь на собеседовании можно задать такую задачку на шаблоны. И прикладной смысл понятен и не особо сложно как будто бы. Use templates in the right place. Stay cool. #template #interview
2 392
14
​​Loop unrolling. Simd #опытным У современных процессоров есть возможность выполнять операции над несколькими значениями одновременно. Достигается это с помощью векторных регистров - специальных длинных регистров процессора, куда могут помещаться 128 бит (SSE), 256 бит (AVX), 512 бит (AVX-512) данных. Например в 256-битном регистре помещается 4 double, 8 float, 16 short, 32 char. После того, как мы загрузили данные в регистры, мы можем с помощью специальных инструкций(Single Instruction Multiple Data - SIMD) обрабатывать разом все значения в регистре. Например есть такой незамысловатый код: for (int i = 0; i < 1024; ++i) { arr[i] += 5; } Здесь мы по очереди увеличиваем элементы массива. Но их можно увеличивать сразу пачками с помощью SIMD интринсиков компилятора: __m128i five = _mm_set1_epi32(5); for (int i = 0; i < 1024; i += 4) { __m128i data = _mm_loadu_si128((__m128i*)&arr[i]); data = _mm_add_epi32(data, five); _mm_storeu_si128((__m128i*)&arr[i], data); } Векторные регистры работают только друг с другом, поэтому в начале помещаем число 5 в каждую 32-битную ячейку в 128-битном регистре. А потом загружаете элементы массива в другой регистр, одной операцией складываете 4 интовых значения и затем выгружаете получившиеся числа обратно в массив. Эту ситуацию называют по-разному - и векторизация цикла и развертка цикла с использованием векторных инструкций. Количество итераций в цикле мы все равно снизили. Вот примерчик чуть по-сложнее - полноценная функция, суммирующая 2 float массива переменной длины: void sum_arrays_sse(const float* a, const float* b, float* c, size_t n) { size_t i = 0; for (; i + 3 < n; i += 4) { // Load 4 floats from a and b __m128 va = _mm_loadu_ps(&a[i]); __m128 vb = _mm_loadu_ps(&b[i]); // Add them all __m128 vc = _mm_add_ps(va, vb); // Store results _mm_storeu_ps(&c[i], vc); } // Handle tail for (; i < n; ++i) { c[i] = a[i] + b[i]; } } Опять же. Важно понимать, что компилятор за вас может такие простые вычисления векторизировать в том числе и с simd инструкциями. Поэтому самостоятельно используйте их только там, где компилятор бессилен. Зато в подходящей для применения simd нетривиальной задаче вы можете получить ускорение в 2-10 раз по сравнению с ванильной версией алгоритма. Когда-то давно я работал в Intel перформанс библиотеках, так там почти все алгоритмы ускорялись интринсиками. И в отдельных случаях ускорение было на порядок(например алгоритмы блочных шифров). Поэтому SIMD - это мощная штука. Но применять их надо применять с умом. И всегда мерять производительность кода бэнчмарками, чтобы не наоптимизировать чего лишнего. Learn to do things simultaneously. Stay cool. #compiler #optimization #performance
2 181
15
Что нужно для запуска крутого продукта? Качественный код, скилловая команда и дух авантюризма. Что нужно, чтобы успешно выкат
Что нужно для запуска крутого продукта? Качественный код, скилловая команда и дух авантюризма. Что нужно, чтобы успешно выкатить лето на прод? Конечно же летний ИТ‑фестиваль «Сезон кода». Там будет и код, и крутые профессионалы, и незабываемая атмосфера: 🟡 Экспертные доклады – реальные кейсы масштабирования, архитектурные боли, инструменты, которые действительно работают. От топовых бигтехов РФ. Секции: — Клиентоориентированный код – про продукты для миллионов пользователей (win‑win, ошибки, находки). — Продуктовая кухня (новое в 2026!) – как данные превращаются в решения, а гипотезы – в рост продукта. — Бэкенд‑методичка – практические инструменты из повседневной инженерии. 🟡 Демозона – создатели бизнес‑платформ Т‑Банка покажут всю внутрянку: как продукты устроены и какие инженерные решения лежат в их основе. 🟡 Интерактив от Т‑Образования – если надоел литкод, а хочется нестандартных задачек. 🟡 Не одним кодом едины – будет лаунж и фотозона, спорт, афтепати с летним DJ‑сетом. И куча нетворка с крутыми инженерами. В этом году "Сезон кода" будет проходить сразу в двух городах: 📍 Санкт‑Петербург – 20 июня, офис Т‑Банка (ИТ‑хаб ГК «Т‑Технологии») 📍 Казань – 4 июля, ИТ‑парк им. Башира Рамеева Переходите по ссылке, регистрируйтесь и проведите выходной за ламповыми разговорами о технологиях.
1 990
16
​​Сколько раз можно разыменовать лямбду? #опытным Давайте для начала поговорим о том, сколько раз можно разыменовать указатель? Зависит от того, какой указатель мы имеем ввиду. Тип int* можно разыменовать всего один раз. Получившийся объект после разыменования будет lvalue int, для которого отсутствует перегрузка operator*: int i = 5; int * p = &i; std::cout << **p << std::endl; // error: indirection requires pointer operand ('int' invalid) Но мы конечно же можем определить указатель на указатель и добиваться очень глубоких уровней индирекции. Однако мы все равно сможем разыменовать такой указатель ровно по количеству этих уровней, не больше: int i = 5; int * p = &i; int ** p1 = &p; std::cout << **p1 << std::endl; // OK std::cout << **p1 << std::endl; // Error Сколько же раз можно разыменовывать лямбду? Так стоп. Как в лямбде можно применять оператор? Лямбда без захвата неявно кастится к указателю на функцию. А указатель можно разыменовывать. Так сколько? Бесконечно #include <iostream> int main() { auto ret = *****************************************[]{ return 23; }; std::cout << ret() << std::endl; } Чисто технически мы конечно ограничены количеством атомов во вселенной или, что более реально, возможностями компилятора обрабатывать длиннющие тексты программ. Но формальных ограничений нет. #ЧЗХ? Давайте подумаем, что происходит при разыменовании лямбды. Она приводится к указателю на функцию, оператор применяется и результатом мы получаем lvalue функции(саму функцию, а не указатель). Тип этого выражения – int(). А функции у нас что любят делать? Правильно, неявно преобразовываться к указателю на саму себя. Поэтому можем применить оператор еще разик. А потом еще и еще. И еще, и еще, и еще, и еще... Ну вы поняли. В любом случае ret по итогам того же неявного преобразования будет иметь тип указателя на фукнцию: static_assert(std::is_same_v<decltype(ret), int()()>); По тем же рассуждениям, кстати, любой указатель на функцию тоже можно бесконечно разыменовать. Спасибо, @Ivaneo, за любезно предоставленный примерчик. Be limited only by your imagination. Stay cool. #cppcore
3 459
17
​​Loop unrolling. pragma unroll #опытным Компилятор конечно умный и сам умеет разворачивать циклы. Но делает он это не во всех случаях. Когда-то уровень оптимизации не позволяет(агрессивная развертка делается на O3) или количество итераций цикла неизвестно. Если же вы хотите вручную контролировать развертку, но не писать код развертки руками - для вас есть один вариант. Это #pragma unroll. Правда, есть загвоздка. Стандартного синтаксиса этой директивы не существует, для каждого компилятора чуть свой синтаксис: -👉🏿GCC и Clang: Используется #pragma GCC unroll Для Clang существует более детализированный синтаксис #pragma clang loop unroll. 👉🏿 MSVC (Microsoft Visual C++): здесь все, как не у всех: в msvc нет прямого аналога директивы #pragma unroll. Есть #pragma loop, но она контролирует скорее автовекторизацию, чем развертку. 👉🏿 Intel и NVIDIA компиляторы: Используют #pragma unroll. 👉🏿 ARM компилятор: Поддерживает #pragma unroll. Раз у всех все свое, то в примерах будет использовать некую усредненную версию. По сути есть 2 возможных варианта использования директивы: 🙈 Полная развертка #pragma unroll for (int i = 0; i < 12; ++i) { p1[i] += p2[i] * 2; } В этом случае компилятор обязан знать число итераций цикла, чтобы полностью его развернуть. Плюс чем больше итераций, тем сильнее распухает код бинаря. Поэтому в данном виде директива встречается не так часто. ☃️ Частичная развертка Это именно та история, которую мы обсуждаем всю серию статей. #pragma unroll N позволяет развернуть цикл так, чтобы за одну итерацию тело цикла выполнялось N раз. #pragma GCC unroll 4 for (size_t i = 0; i < n; ++i) { sum += arr[i]; } Как прагма работает? 1️⃣ Препроцессор оставляет #pragma unroll после препроцессинга в единице трансляции для того, чтобы компилятор смог ее учесть. 2️⃣ На этапе семантического анализа компилятор встречает #pragma unroll и связывает его со следующим за ним циклом. 3️⃣ Специальная сущность в компиляторе, отвечающая за развертку циклов, слушает и повинуется прагме и делает столько операций в цикле, сколько сказал разработчик. Плюс добавляет обработку хвоста. Здесь можно посмотреть примерчики с gcc-шной версией прагмы. Напомню, что компилятор сам хорошо умеет оптимизировать циклы, особенно на O3. Используйте прагму только тогда, когда вы сами ручками проверили, что компилятор в вашем случае бессилен или делает что-то неправильно. Плюс обязательно делайте перфоманс тестирование, чтобы выявить действительно лучший по производительности вариант. Measure your performance. Stay cool. #compiler #optimization #performance
3 361
18
​​Loop unrolling. Duff's device #опытным Если у вас чуть подвытекали глаза, когда вы увидели в прошлом посте эту схему со свитчами и гоуту, то сегодня они вытекут окончательно. Если приглядеться в механику этого кода switch (r) { case 3: goto rest3; case 2: goto rest2; case 1: goto rest1; default: goto main_loop; } rest3: output[i] = i * i; ++i; rest2: output[i] = i * i; ++i; rest1: output[i] = i * i; ++i; main_loop: for (; i < count; i += 4) { output[i] = i * i; output[i + 1] = (i + 1) * (i + 1); output[i + 2] = (i + 2) * (i + 2); output[i + 3] = (i + 3) * (i + 3); } То можно заметить одну вещь. Мы используем возможность беспретятственного прохождения исполнения сквозь метки при этом имеем возможность воткнуться в любой момент времени, перейдя по нужной метке. Но эта механика же полностью закрывается функциональностью switch. Код может проходить от кейса к кейсу, пока не встретит break и изначально можно перейти в любой из кейсов. Давайте сделаем трах-тебедох и представим код в другом виде: if (count == 0) return; size_t i = 0; size_t n = (count + 3) / 4; switch (count % 4) { case 0: do { output[i] = i * i; i++; case 3: output[i] = i * i; i++; case 2: output[i] = i * i; i++; case 1: output[i] = i * i; i++; } while (--n > 0); } Как это работает 👉🏿 n – число полных итераций развёрнутого цикла, необходимых для обработки всех элементов (с учётом возможного неполного первого прохода). 👉🏿 switch (count % 4) выбирает точку входа в тело цикла do-while в зависимости от остатка. При остатке 0 начинаем с начала (полная итерация), при остатке 3 – с третьей операции и т.д. 👉🏿 Тело цикла содержит четыре одинаковых операции (вычисление квадрата и сдвиг индекса), размеченных метками case. За счёт проваливания (fall-through) первая (неполная) итерация выполняет ровно столько операций, сколько нужно для обработки хвоста, а все последующие итерации – по четыре операции. 👉🏿 Цикл продолжается, пока --n &gt; 0, что гарантирует обработку всех элементов. Первым такую особенность заметил мисье Том Дафф. В тот момент он работал в Lucasfilm и пытался оптимизировать копирование данных в memory-mapped регистр для анимации реального времени. Ему нужно было развернуть цикл для скорости, но была проблема — количество итераций не всегда кратно размеру развёртки. Он вдохновился приемами из ассемблера и написал на языке K&R С(одна из ранних версий С, до стандартизации) вот такую конструкцию: send(to, from, count) register short *to, *from; register count; { // Begin of the function register n = (count + 7) / 8; switch (count % 8) { case 0: do { *to = *from++; case 7: *to = *from++; case 6: *to = *from++; case 5: *to = *from++; case 4: *to = *from++; case 3: *to = *from++; case 2: *to = *from++; case 1: *to = *from++; } while (--n > 0); } } Функция send - это такой аналог memcpy, до внедрения в стандарт этой функции. Идея такая же: прыгаем в середину свитча для обработки остатка, а дальше крутимся в развернутом while, пока не скопируем все элементы. С тех пор код такого вида называется Duff's device или устройство Даффа. В 80-е техника давала буст производительности хоть и вызывала вопросы к читаемости кода и в целом к средствам языка, которые предоставляют такие возможности. Но в современном мире не стоит даже и пытаться нечто такое написать в проде.Компиляторы cами отлично разворачивают циклы, часто лучше, чем ручная оптимизация. И вообще перед применением любой оптимизации кода следует выполнить его бенчмаркинг или изучить сгенерированный компилятором вывод, чтобы убедиться, что код работает ожидаемым образом на целевой архитектуре, уровне оптимизации и компиляторе. Тем не менее Duff's device остался в истории как пример вырвиглазной эксплуатации тонкостей языка в гонке за производительностью. Be relevant. Stay cool. #compiler #optimization #performance
3 621
19
​​Loop unrolling. Быстрая обработка хвостов #новичкам В прошлый раз мы пришли к важной мысли: нам нужно обрабатывать хвосты развернутых циклов и делать это быстро. В С++ коде пока непонятно, как это сделать. Но в ассемблере есть довольно старая техника для этого. Возьмем С++ код: void output_squares(size_t count, size_t *output) { for (size_t i = 0; i < count; ++i) { *output++ = i * i; } } В целом, мы хотим, чтобы хвосты обрабатывались примерно так: #include <cstddef> void output_squares(size_t count, size_t *output) { size_t i = 0; size_t remainder = count % 4; // Эта часть назвается пролог if (remainder == 3) { output[2] = 2 * 2; output[1] = 1 * 1; output[0] = 0 * 0; i = 3; } else if (remainder == 2) { output[1] = 1 * 1; output[0] = 0 * 0; i = 2; } else if (remainder == 1) { output[0] = 0 * 0; i = 1; } for (; i < count; i += 4) { output[i] = i * i; output[i + 1] = (i + 1) * (i + 1); output[i + 2] = (i + 2) * (i + 2); output[i + 3] = (i + 3) * (i + 3); } } Только без кучи повторяющегося кода, количество которого будет только увеличиваться с увеличением фактора разворачивания цикла. Идея: на месте пролога напишем 4 инструкции присваивания, а между ними поставим метки. В начале мы узнаем остаток и прыгаем на нужную метку. После чего раз за разом проваливаемся в следующую метку и в итоге в развернутый цикл. То есть в начале обрабатываем хвост правильного размера с помощью меток, а потом уже попадаем в основной цикл. В переводе на С++ это выглядит так: void output_squares(size_t count, size_t *output) { size_t i = 0; size_t r = count % 4; switch (r) { case 3: goto rest3; case 2: goto rest2; case 1: goto rest1; default: goto main_loop; } rest3: output[i] = i * i; ++i; rest2: output[i] = i * i; ++i; rest1: output[i] = i * i; ++i; main_loop: for (; i < count; i += 4) { output[i] = i * i; output[i + 1] = (i + 1) * (i + 1); output[i + 2] = (i + 2) * (i + 2); output[i + 3] = (i + 3) * (i + 3); } } Выглядит адски: свитч, какие-то метки, на которых одинаковый код, goto. За такое сразу бы запретили человеку коммитить код, только документацию писать отныне и навсегда. Поэтому никто так не пишет. Зато эту лапшу можно спрятать в скомпилированном ассемблере, который вряд ли кто-то будет читать. Компилятор превращает исходный С++ код из начала поста в ассемблер примерно такого же вида, как последний сниппет. Вот примерчик на годболте как это выглядит в оригинале. Hide your impurities deep. Stay cool. #performance #compiler
3 231
20
​​Loop unrolling. Хвосты #новичкам Предыдущий пост тут. Следующие пару постов могут быть немного скучными и замудренными, но темы по смыслу именно так бьются. Разговор о хвостах нужен, чтобы правильно понять одну технику(спойлер: Duff device) Удобненько получилось, что в прошлом посте число итераций цикла делилось на 4. 1024 % 4 = 0. int sum = 0; for (int i = 0; i < 1024; i += 4) { sum += arr[i]; sum += arr[i+1]; sum += arr[i+2]; sum += arr[i+3]; } Но как разворачивать циклы, если число итераций не делится нацело на фактор развертывания(количество повторов операции в развертке)? Да тем же циклом. Обрабатываем целую часть развернутым циклом и потом дорабатываем остаток: int sum = 0; int i = 0; int unroll_factor = 4; int main_limit = n - n % unroll_factor; for (; i < main_limit; i += unroll_factor) { sum += arr[i]; sum += arr[i + 1]; sum += arr[i + 2]; sum += arr[i + 3]; } // HERE for (; i < n; ++i) { sum += arr[i]; } Последний цикл - это и есть дообработка хвоста. И обработка хвостов - это важная штука, которую нельзя забывать хоть при разворачивании цикла, хоть при векторизации вычислений, хоть при распараллеливании вычислений. Чем вообще плох хвост? Мы заранее не знаем его размер и поэтому вынуждены использовать привычный неразвернутый цикл для его обработки. Это опять же приносит с собой недостатки обычных циклов. Если бы мы только знали, сколько элементов в хвосте коллекции, то смогли бы и этот последний цикл развернуть просто в набор операций. И есть несколько техник, которые позволяют сэмулировать нечто подобное. О них мы поговорим в следующие разы, они отдельных постов заслуживают. See the job through. Stay cool. #performance
3 590