tgindex
Грокаем C++

Грокаем C++

Статистика

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

Последний пост
15 авг.
Последнее чтение
18:18
Постов за неделю
2
Всего постов
289
Тип
открытый
Язык
русский
Категория
Технологии (по похожим)
В каталоге с
12 авг.
Подписчики
9 324
+6 за 4 дн.
Сутки
0
0,00%
Неделя
 
Месяц
 
Просмотров на пост
2 981
40 постов
Вовлечённость
32,0%
к подписчикам
Постов в день
0,3
всего 289
Упоминаний
1
каналов
Охват размещения
оценка
1/24сутки в ленте
1 432
1/48двое суток
1 641
1/72трое суток
1 769

Оценка по просмотрам недавних постов: пост набирает почти всё за первые сутки.

Посты

  • 15 авг.1 1473610

    ​​std::inplace_vector #опытным Стандартная библиотека традиционно запаздывает с внедрением полезного функционала. Вот у нас есть std::vector. Прекрасный контейнер, расширяемый. более менее все им пользуются. Однако у него есть проблема - динамические аллокации. От них не уйти. А если вам они не нужны, то вы вынуждены использовать другие инструменты. Да и еще и ограниченное использование вектора в constexpr контексте. Ну ладно. Есть std::array. Нет скрытых аллокаций, давно можно использовать в constexpr. Красота. Но как бы не так: не расширяемый он. Еще и элементы должны быть созданы сразу все и должны соответствовать требованию DefaultConstructable. Короче опять недостатки. Но в С++26 появился контейнер, который объединяет преимущества std::vector и std::array. Называется он std::inplace_vector. По сути это динамически расширяемый массив с фиксированной в compile-time емкостью: 1️⃣ Элементы массива хранятся прям внутри объекта, поэтому нет никаких дополнительных аллокаций. 2️⃣ Объект inplace_vector сразу при создании содержит буфер размера capacity. 3️⃣ Можно создать объект без элементов вообще и изменять их набор как угодно во время выполнения программы. Главное не превышать capacity. 4️⃣ Так как этот массив не предполагает дополнительных динамических аллокаций, то контейнер можно полноценно использовать в constexpr контексте. Рассмотрим небольшой примерчик, где нам нужно отфильтровать из std::array пложительные числа и возвести их в квадрат: template<size_t N> constexpr std::inplace_vector<int, N> square_positive(const std::array<int, N>& arr) { std::inplace_vector<int, N> result; for (int x : arr) { if (x > 0 && result.size() < result.capacity()) { result.push_back(x * x); } } return result; } int main() { constexpr std::array<int, 6> data = {-3, 5, -1, 7, 0, 4}; constexpr auto squares = square_positive<6, 6>(data); static_assert(squares.size() == 3); static_assert(squares.capacity() == 6); static_assert(squares[0] == 25); static_assert(squares[1] == 49); static_assert(squares[2] == 16); return 0; } Мы не можем заранее сказать, сколько элементов вернет функция square_positive. Но мы можем гарантировать, что их количество <= размеру входного массива. Создается inplace_vector пустым и элементы накидываются в него по очереди. Проверки static_assert гарантируют, что все вычисления происходят в compile-time. Еще одна особенность - у inplace_vector меньше требований к типу своих элементов: struct S { S() = delete; S(int) { } }; std::array<S, 5> arr; // compile error: S does not have a default constructor std::inplace_vector<S, 5> inplace_vec; // ok Так как std::array создает сразу все свои элементы, конструирование arr завершится ошибкой, потому что S не имеет конструктора по умолчанию. Но inplace_vec успешно создается, потому что не имеет этого требования. Также std::inplace_vector - хороший пример того, что стандартная библиотека в новых стандартах старается поддерживать апи с использованием исключений и без них. Есть принципиально 2 разных подхода к добавлению элементов в этот массив: 👉🏿 constexpr reference push_back( const T& value ) — классический вариант, который выбрасывает std::bad_alloc при попытке превысить capacity. 👉🏿 constexpr std::optional<reference> try_push_back( const T& value ) - добавляет элемент и возвращает на него ссылку или не добавляет элемент и возвращает std::nullopt. То есть ошибка обрабатывается с помощью возвращаемого значения Есть кстати еще метод constexpr reference unchecked_push_back( const T& value );, который забивает на все проверки и перекладывает эту ответственность на пользователя. В случае превышения емкости получаем UB. В общем, крутой новый контейнер, который явно найдет себе место в системах с ограничениями кучи или суперпроизводительном софте. Combine advantages. Stay cool. #cpp26 #STL

  • 10 авг.2 0822718

    Мапим значения на типы. Ч2 #опытным Напомню проблему: enum class DataType: uint8_t { kInt32, kDouble, kUint64 }; template<typename T> T create_from_buffer(const void* buffer) { static_assert(std::is_trivially_copyable_v<T>, "Type T must be trivially copyable"); T obj; std::memcpy(&obj, buffer, sizeof(T)); return obj; } Как на основе DataType создать объект соответствующего типа? Compile-time мапа из массива, где по индексу подкапотного числа, стоящего за каждым перечислителем, позволяет получить соответствующий нужный тип и выбрать правильный обработчик. И все это за О(1) с тратами на сдвиг элемента в массиве Если же перечислителям явно присвоены числа, то этот номер не прокатит и надо думать дальше. Без шаблонной магии нам не обойтись, ведь мы хотим только добавлять маппинги, но не менять самого кода обработчиков и логику их выбора. И давайте заодно попробуем уйти от полной специализации шаблонов, как было в предыдущей части. Сделаем структуру аля ноду маппинга: template <DataType D, typename T> struct Node { static constexpr DataType key = D; using type = T; }; В структуре хранится конкретный перечислитель, получаемый из шаблонного параметра, и соответствующий ему тип. И в качестве пака шаблонных параметров нашей шаблонной функции мы будем передавать список нод Node<DataType::kInt32, int32_t>, Node<DataType::kDouble, double>, Node<DataType::kUint64, uint64_t>. В функции мы должны сделать примерно следующее. Перебираем рантайм значение enum'а на соответствие значению Key из нод пака параметров. Перебираем, пока не найдем нужный и после используем правильную ноду для получения замаппленого типа. И вот этот перебор можно делать через fold expression и короткозамкнутый оператор ||. Как только условие истинно, вычисления прекращаются. template <typename... Es> DataVariant ConvertImpl(const void* buffer, DataType type) { DataVariant result; bool found = ((Es::key == type ? (result = create_from_buffer<typename Es::type>(buffer), true) : false) || ...); if (!found) throw std::runtime_error("Unknown type"); return result; } DataVariant Convert(const void* buffer, DataType type) { return ConvertImpl< Node<DataType::kInt32, int32_t>, Node<DataType::kDouble, double>, Node<DataType::kUint64, uint64_t> >(buffer, type); } Также тут используется фишка оператора запятой, что результатом выражения является только самый крайний справа операнд. Теперь вместо поиска элемента в массиве по индексу мы занимаемся линейным проходом выполнения логического ИЛИ для типов. На первый взгляд это может быть сильно дольше, но компиляторы хорошо умеют оптимизировать fold expression, даже в худшем случае большой разницы не будет. Код кстати по размеру уже не сильно больше изначального свитча. Вот примерчик с рабочим кодом, можете поиграться. Use tricks. Stay cool. #template #cpp17

  • 9 авг.2 1834128

    Мапим значения на типы. Ч1 #опытным Иногда требуется на основе какого-то рантайм значения, например перечисления, получить какой-то соответствующий тип. Например, у вас есть шаблонная функция и вы хотите вызвать правильную ее инстанциацию: enum class DataType { kInt32, kDouble, kUint64 }; template<typename T> T create_from_buffer(const void* buffer) { static_assert(std::is_trivially_copyable_v<T>, "Type T must be trivially copyable"); T obj; std::memcpy(&obj, buffer, sizeof(T)); return obj; } Как на основе DataType создать объект нужного типа? Пойдем доисторическим способом. Там, где есть enum, всегда где-то в углу стоит застенчивый switch и хочет быть использован. Ну давайте попробуем: using DataVariant = std::variant<int32_t, double, uint64_t>; DataVariant foo(const void* buffer, DataType type) { switch (type) { case DataType::kInt32: return create_from_buffer<int32_t>(buffer); // ... default: throw std::runtime_error("Unknown type"); } } И это вроде работает. Но не зря switch стоит в углу: 1️⃣ Он смешивает логику выбора, соответствия сущностей и обработки 2️⃣ Часто он приводит к длинным функциям, которые сложно поддерживать 3️⃣ switch может и быстрый, но засоряет клиентский код. К тому же код кучу раз повторяется конкретно в этом кейсе. Давайте пробовать решать. Если наш enum плотный aka вы не присваивали никаким перечислителям числа, то можно примерно все красиво вынести в compile-time. Начнем с того, что нужно создать compile-time маппинг между типами и перечислителями. Это поможет в коде отделить логику соответствия enum'а и типов: enum class DataType { kInt32, kDouble, kUint64, kCount }; template <DataType> struct EnumMap; template <> struct EnumMap<DataType::kInt32> { using Type = int32_t; }; template <> struct EnumMap<DataType::kDouble> { using Type = double; }; template <> struct EnumMap<DataType::kUint64> { using Type = uint64_t; }; template <DataType D> using EnumMappedType = typename EnumMap<D>::Type; Используем полную специализацию шаблонов и зависимые типы. Дальше нужна логика обработки using DataVariant = std::variant<int32_t, double, uint64_t>; using MakerFn = DataVariant(*)(const void*); template <std::size_t... Is> constexpr auto make_table(std::index_sequence<Is...>) { return std::array<MakerFn, sizeof...(Is)>{ +[](const void* buffer) -> DataVariant { return create_from_buffer<EnumMappedType<static_cast<DataType>(Is)>>(buffer); }... }; } static constexpr auto dispatcher = make_table(std::make_index_sequence<static_cast<std::size_t>(DataType::kCount)>{}); Через раскрытие пака параметров делаем массив обработчиков для каждого элемента перечисления. Это гарантирует std::make_index_sequence<static_cast<std::size_t>(DataType::kCount)>, который раскрывается в последовательность индексов перечислителей вплоть до последнего kCount. Для этого и было условие плотности enum'а, чтобы make_index_sequence корректно передавал индексы. Для каждого обработчика выбираем нужный тип через EnumMappedType. Плюсик перед лямбдой кастит ее к указателю на функцию. Это чтобы не использовать опасный для перфа std::function. Ну а логику выбора написать уже очень просто: DataVariant foo(const void* buffer, DataType type) { auto idx = static_cast<std::size_t>(type); if (idx >= dispatcher.size()) throw std::runtime_error("Unknown type"); return dispatcher[idx](buffer); } Итого, мы явно разделили 3 задачи: описание обработчиков, маппинг и сам выбор обработчика. Да, код все еще повторяется при описании маппинга EnumMap. Однако этот код намного более атомарно фундаментальный чтоли. Намного менее вероятно, что он изменится, потому что в нем зашита только необходимый маппинг и больше ничего. Можно делать через туплы парных сущностей, но выглядело бы это сильно менее понятным. Divide and conquer. Stay cool.

  • 5 авг.2 7025822

    ​​Ответ #опытным 2 вещи нужно знать(помимо всего остального С++😆), чтобы правильно ответить на квиз выше: 1️⃣ Сокрытие имен Когда в классе объявляют метод с неким именем, все методы с этим именем из базовых классов становятся невидимыми (скрытыми), независимо от их параметров. То есть struct A { void func(const std::string&); }; struct B : A { void func(float); }; B b; у объекта b можно вызвать только флотовый вариант func. Почему так? Компилятор увидел вызов метода и должен выполнить overload resolution. Он видит, что у объекта b статический тип B и идет смотреть, какие методы из этого класса подходят для вызова. И вот здесь срабатывает ключевая особенность поиска. Компилятор ищет имя func по цепочке областей видимости — снизу вверх (от производного класса к базовому). Как только он находит хотя бы одно объявление с именем func, поиск по иерархии немедленно останавливается . Дальше вверх (в A) он уже не заглядывает. В классе B есть func(float) — имя найдено. Поиск завершён. В набор кандидатов для overload resolution попадает только B::func(float). Метод A::func(const std::string&) в этот набор даже не рассматривается — он остался за границей поиска. Поэтому: b.func(15.f); // OK: float b.func("hello"); // Compile Error 2️⃣ Вообще говоря, это сокрытие имен - это проблема с точки зрения привычного нам ООП. "У меня есть базовый класс, от которого я наследую функциональность. Почему я не могу использовать метод базового класса?" Сокрытие имен - процесс неявный. Но мы можем явно сказать, какой сокрытый метод мы как разработчики хотим видеть в наследнике. С помощью директивы using можно вернуть видимость методу базового класса в наследник: struct A { void func(const std::string&); }; struct B : A { using A::func; void func(float); }; B b; b.func(15.f); // OK b.func("hello"); // OK using A::func; позволяет компилятору увидеть метод, принимающий строку, и успешно вызывать его при передаче строкового литерала. Такой же прием кстати используется в реализации overloaded паттерна. Когда мы это знаем, правильные ответы находятся на поверхности: struct A { void func(const std::string&); // #1 }; struct B : A { void func(float); // #2 }; struct C : B { using A::func; void func(int); // #3 }; C c; B& b = c; c.func(3.14f); // вызывает #3 за счет неявного приведения float к int c.func(123); // вызывает #3 c.func("hello"); // вызывает #1 за счет using b.func(3.14f); // вызывает #2 Don't let others shadow you. Stay cool #cppcore

  • 4 авг.2 68329

    видео или голосовое, без подписи

  • 4 авг.2 5721910

    Квиз Выберете правильные утверждения о вызовах в коде: struct A { void func(const std::string&); // #1 }; struct B : A { void func(float); // #2 }; struct C : B { using A::func; void func(int); // #3 }; C c; B& b = c;

  • 2 авг.2 6714420

    ​​Оверхэд системных вызовов #опытным Как измерить оверхэд системного вызова? Задача ведь не самая тривиальная: сискол делает разную полезную работу, которая потенциально занимает сильно больше времени, чем переход в режим ядра и обратно. И вставить свой измеряющий код в место начала и конца логики сискола мы тоже не можем - это часть скрыта от нас и вообще написана на ассемблере. Что делать? Брать самый легковесный сискол. Который делает минимальную работу. Список сисколов можно найти в man'е. Изучив их поближе, я понял, что один и самых дешевых системных вызовов это getpid. Все, что он делает - возвращает ID процесса. По сути одно чтение из памяти. Однако библиотечная функция getpid() не всегда делает системный вызов. Айди процесса не меняется и это отличный кандидат для кэширования результата. И по сути эта библиотечная обертка делает сискол только в первый свой вызов. Дальше результат кэшируется в обертке и выдается из кэша. Как обойти эту неприятность? Было бы круто уметь напрямую вызывать сискол без этих оберток со своей логикой. Как мы уже говорили, напрямую(без библиотечных оберток) вызывать сискол можно только с ассемблерными вставками. Однако есть функция syscall, которая принимает первым параметром номер системного вызова и далее его аргументы с помощью вариабельного параметра: #include <unistd.h> #include <sys/syscall.h> #include <sys/types.h> int main(int argc, char *argv[]) { pid_t pid; pid = syscall(SYS_getpid); } Таким образом мы избегаем кэширования и всегда делаем честный сискол. Который делает одно чтение из памяти и возвращает результат. То есть фактически, измеряя затраты на вызов getpid, мы очень близко подбираемся к реальному оверхэду на переход в режим ядра и обратно. Теперь обсудим несколько принципов, которые нужно учесть в измерениях, чтобы они были более точные: ✅ Большое количество прогонов - естественно. Флуктуации бывают везде, нужно их усреднять за счет большого количества экспериментов. ✅ Запрещаем компилятору оптимизировать результат. Компилятор хитрый: если он видит, что результат вызова не используется(а нафига нам нужны миллионы значений айди текущего процесса?) он пытается выкинуть весь вызов. Делаем ассемблерную вставку, запрещающую оптимизировать конкретно это место в коде. ✅ Прогрев. Первые вызовы всегда медленнее: кэши холодные, предсказатель ветвлений не обучен, процессор ещё не разогнался до турбо-частоты. Сделаем некоторое количество «холостых» вызовов до начала измерений, чтобы система вошла в стабильный «горячий» режим. Иначе первые тысячи медленных вызовов исказили бы результат. ✅ Вычесть стоимость самого измерения. Подсчет времени сам по себе требует времени на выполнение, поэтому это нужно учесть. ✅ Измерение с выборкой. Делаем 100 млн замеров для имитации бурной деятельности, но замеряем только небольшую часть, чтобы вывести подробную статистику с пенцентилями и меньше привносить эффекта наблюдателя в измерение. Я навайбкодил программулину, которая учитывает эти штуки и делает измерение стоимости вызова чистого сискола getpid. Результаты вышли вот такими: getpid() syscall benchmark (macOS) Method : syscall(SYS_getpid) Iterations : 100,000,000 Total time : 9.536 s Average time: 95.36 ns per call Calls/sec : 10,486,821 calls/sec (approx) Min : 18 ns Median : 84 ns P50 : 84 ns P90 : 125 ns P99 : 125 ns Max : 28726 ns CPU : Apple M4 Pro Kernel : Darwin 24.6.0 (arm64) Compiler : clang++ (Clang) 16.0.0 Flags : -O3 -Wall Получилось, что медианное время - 84 нс. Что при частоте процессора около 4.5 Ггц дает около 380 клоков процессора. Видел где-то в статье чувак тестил на интеле и у него вышло около 130 нс и примерно 350 циклов проца. Если среди нас есть спецы по перф измерениям, подскажите, что можно было сделать, чтобы результаты были более приближены к реальности? Measure your performance. Stay cool. #performance #OS

  • 30 июл.3 1144737

    ​​Что происходит при системном вызове? #опытным Существует строгая граница между приложениями, которые вы запускаете, и ядром компьютера (аппаратным обеспечением и ядром ОС).  Зачем она нужна? Пользователям нельзя доверять. Нужно оградить процессы, чтобы они не пострадали от действий другого процесса(написанного говнокодером или хакером). Системный вызов является фундаментальным интерфейсом между приложением и ядром системы. Только через него пользователи могут запросить у системы выполнение какой-то задачи. Для большинства сисколы - это просто хэндлеры, которые они дергают, чтобы получить нужный результат. Но там много интересностей происходит под капотом и именно об этом пойдет сегодня речь. Будем все рассматривать на примере linux, как самой популярной серверной ОС. 0️⃣ В коде все начинается с какой-нибудь обертки для системного вызова Оператор<< для плюсового объекта потока принимает высокоуровневые объекты и вызывает сискол write, чтобы записать данные в поток. Но даже если вы напрямую из кода вызываете write, то вы вызываете обертку POSIX API, которая уже сама дергает одноименный системный вызов. Без оберток напрямую сисколл можно вызывать только ассемблерными вставками и прочей магией. int main() { write(1, "Hello\n", 6); return 0; } 1️⃣ Подготовка данных в пользовательском пространстве Функция обертка при необходимости делает некий препроцессинг аргументов(например форматирование в функции printf). После копирует номер системного вызова и его готовые аргументы в определённые регистры процессора, где ядро ожидает их найти. В архитектуре x86-64 для Linux это делается так: - В rax кладется номер системного вызова. - В rdi, rsi, rdx, r10, r8, r9 кладутся аргументы (не больше шести). Дальше обёртка выполняет специальную инструкцию, которая инициирует переход в ядро. Например, syscall (в 64-битном режиме x86-64). 2️⃣ Переключение в режим ядра Как и инструкция call, syscall делает какую-то работу. Только в отличие от call, она минимальна: 👉🏿 сохраняет адрес возврата (следующую инструкцию) в регистр %rcx; 👉🏿 сохраняет регистр флагов RFLAGS в %r11; 👉🏿 загружает новый указатель инструкций из MSR-регистра IA32_LSTAR_MSR (там лежит адрес функции entry_SYSCALL_64 - входной точки для всех сисколов). После выполнения инструкции syscall ядро получает доступ к привилегированным ресурсам: управлению памятью, прерываниями, устройствами ввода-вывода. 3️⃣ Обработка в ядре Управление передаётся функции entry_SYSCALL_64 - единой точки входа в ядро. Здесь начинается основная работа. И в первую очередь происходит смена контекста - ядро сохраняет значения регистров текущего потока и заменяет пользовательский стек на стек ядра. Это нужно для лучшей защиты от злонамеренного изменения стека ядрённых вызовов. После нужно найти конкретный обработчик. Ядро извлекает номер системного вызова из %rax и проверяет, не выходит ли он за допустимые пределы. После номер вызова используется как индекс в глобальной таблице ядра – sys_call_table. Это массив указателей на функции-обработчики. Перед выполнением операции ядро проверяет, есть ли у текущего процесса достаточно прав (например, на запись в файл). И затем вызывается нужная функция, внутри которой и выполняется полезная логика сискола. 4️⃣ Возврат в пользовательский режим После того как обработчик завершил работу происходит следующее: 👉🏿 Сохранение результата – возвращаемое значение обработчика помещается в регистр %rax. Если произошла ошибка – возвращается отрицательное число (код ошибки). 👉🏿 Восстановление контекста – ядро восстанавливает сохранённые пользовательские регистры и стек. 👉🏿 Обратный переход – выполняется инструкция sysret. Она использует сохранённые в %rcx и %r11 значения, чтобы восстановить адрес возврата и флаги, переключает процессор обратно в пользовательский режим и передаёт управление коду, следующему за syscall. И самый главный вопрос здесь: а какой оверхэд приходится на обеспечение системного вызова(не учитывая его логику)? В следующем посте обсудим это. Be privileged. Stay cool. #OS #howitworks

  • 25 июл.3 6824616

    ​​Есть ли в С++ отрицательный ноль? #опытным Вопрос странный, но тем не менее проверяет кучу вещей: ваши знания базы, подкапотных механизмов и изменений в стандартах. Поехали Какие у нас вообще числа есть? Знаковые и беззнаковые целые, а также вещественные. Начнем с простого. В беззнаковых числах не может быть отрицательного нуля, очевидно. А вот со знаковыми уже начинаются вопросы. Да, в матеше ноль в целых числах представлен в единственном экземпляре. А компьютеры не всегда могут эмулировать в точности математические концепции. Про целые числа у нас есть серия постов: затравка и начало серии. Так вот С++ разрешал использование методов обратного кода и знак-амплитуда для представления отрицательных чисел. И у них есть положительный и отрицательный ноль. Но в С++20 стандарте четко зафиксировали использование дополнительного кода, в котором только один ноль. А что с вещественными числами? Тут вообще зоопарк. Стандарт говорит, что формат вещественных чисел задается компилятором по своему усмотрению. Но большинство реализаций выбирают IEEE 754. Там вещественное число задается тремя параметрами: знак, экспонента и мантисса. Значение можно вычислить по такой формуле: value=(−1)^S × 2^E × (1+M) Ноль задается нулевой мантиссой и экспонентой. Но у нас же есть и знаковый бит еще. Поэтому на большинстве платформах в С++ есть отрицательный вещественный ноль. Кстати наряду с положительными и отрицательными бесконечностями. Be positive. Stay cool. #compiler #base #cpp20

  • 21 июл.3 9424114

    ​​Ответ на квиз #новичкам Напомню код: std::string makeText() { std::string s = "Hello World!"; return s; }   int main() { std::string t = makeText(); std::cout << t << endl; } В С++ все по классике зависит от кучи деталей. Но в первую очередь от версии стандарта и компилятора. Поэтому в теории, ответом могут быть и 0, и 1, и 3. Но давайте сузим спектр обсуждений до С++23 и gcc. Во времена, когда я только начинал изучать C++, мой ответ был бы «3»: один раз для начального выделения памяти под строку s, затем ещё два — для возврата по значению: сначала копирование-инициализация временного объекта из s, затем копирование-инициализация t из временного объекта. Каждое копирование должно триггерить аллокацию нового буфера. Потом я узнал об оптимизации copy elision(с С++17 есть в стандарте) и конкретно NRVO и понял, что при возврате по значению умный компилятор скорее всего избежит создания трёх объектов, и сразу создаст объект с нужной строкой в main. После этого мой ответ был бы уже 1. Но это ещё не всё. От std::string не требуется выделять память в куче (или любую другую память, о которой знает его аллокатор). Требуется лишь, чтобы он мог тем или иным способом хранить строку символов произвольной длины. Действительно, в общем случае это подразумевает выделение памяти в какой-то момент. Но наш случай не общий вообще-то. Текст "Hello World!" довольно короткий. Это 12 символов; 13, если считать завершающий нулевой символ. Типичная реализация std::string требует трёх указателей для полноценного функционирования (примерно также как мы рассказали тут для std::vector). Если на 64-разрядной машине размер указателя равен 8 символам, то наш текст занимает меньше, чем два указателя. Текст достаточно мал, чтобы поместиться внутрь объекта, память для которого выделена на стеке. Это идеальный кандидат для оптимизации малого буфера (small buffer optimization, SBO). Подробнее про эту оптимизацию и как она реализована мы поговорим в следующих постах. Сейчас просто скажу, что эта оптимизация действительно позволяет аллоцировать небольшие строки внутри объекта не прибегая к динамическим аллокациям. Вообще говоря, даже если не будут работать оптимизации copy elision, аллокаций все равно не будет, потому что для копирование маленькой строки нужно всего лишь данные со стека скопировать. Получается, что в коде выше вообще нет аллокаций. Вот пруф. И это даже без указания флажков оптимизации. И это прекрасно! Run faster. Stay cool. #memory #optimization

  • 20 июл.3 46215

    видео или голосовое, без подписи

  • 20 июл.3 6451912

    Квиз #новичкам Очень простой #quiz для по С++, который открывает ящик пандоры и довольно сложные концепции в том, как все под капотом реализовано. Так вот. Контрибьютеры языка, компиляторы и создатели популярных либ прикладывают большие усилия к тому, чтобы ваш код бегал все быстрее и быстрее. std::string makeText() { std::string s = "Hello World!"; return s; }   int main() { std::string t = makeText(); std::cout << t << endl; } std::string - это стандартный контейнер, который может хранить динамически изменяемые строки почти любого размера. Делает он это примерно как std::vector - без динамических аллокаций тут не обойтись. Что мы знаем про динамические аллокации? Их любят и ненавидят. Любят за то, что позволяют делать строить сложные динамические изменяемые структуры. Ненавядят за то, что они чертовски медленные. Поэтому лучше их избегать. Чтобы понять, как и куда бежать, надо понять, где мы сейчас находимся. Так вот вопрос: сколько динамических аллокаций будет в коде выше? Run faster. Stay cool.

  • 18 июл.3 6614318

    ​​Сколько процессов можно запустить на одной машине? #опытным Симметричный вопрос относительно предыдущего поста, который затрагивает важные различия ОС. В windows довольно простой ответ. Жестких лимитов на количество процессов нет. Разве что PID - это 32-битное число, поэтому больше физически создать нельзя. На практике вы намного раньше упретесь в ограничения ресурсов самой машины и в основном памяти. Каждый процесс требует какого-то количества данных для обеспечения управления им. Плюс у процесса есть как минимум один главный поток и ему нужен свой стек. С линукс чуть интереснее. В его ядре вообще нет процессов per se. Есть задачи. Ядро Linux имеет параметр kernel.pid_max, который определяет максимально возможный идентификатор задачи. Исторически значение по умолчанию было 65535, но в современных ядрах оно может быть значительно выше — до нескольких миллионов. Плюс для каждого пользователя действует ограничение nproc(number of processes). Его можно посмотреть командой ulimit -u: 👉🏿 По умолчанию это значение часто составляет 1024 👉🏿 Ограничение бывает "мягким" (soft) и "жестким" (hard). Пользователь может увеличить мягкий лимит, но не выше жесткого. 👉🏿 Эти лимиты настраиваются в файлах /etc/security/limits.conf или /etc/security/limits.d/. Допустим, у вас лимит в 100 задач. Вы можете запустить 50 процессов, в каждом из которых по 1 потоку. Или 1 процесс и 99 потоков внутри него. Главное, чтобы сумма не превысила 100. Ну и рассуждения про ограничения ресурсов машины здесь тоже имеют место быть. Scale up. Stay cool. #concurrency #OS

  • 17 июл.3 0584122

    ​​Сколько потоков можно запустить на одной машине? #опытным Из всех утюгов говорят про асинхронность вычислений и экономию ресурсов ОС. Зачем это нужно? И почему я не могу на каждую задачу запускать отдельный поток? Есть ли вообще какой-нибудь предел по количеству потоков? Давайте раззззбираться. Потоки - вещь хорошая и полезная. Не зря же недавно в питон таки завезли возможность их прямого использования. Но с потоками нужно быть аккуратным. Это ресурс. А ресурсы имеют свойство заканчиваться, когда их нерационально используют. Классический пример: к вам в приложение приходит какой-то запрос на обработку. Первая мысль - а давайте под него выделим свой поток! Они же независимо и параллельно исполняются! Ну и уже здесь мы поймали первую ошибку. Да, потоки могут параллельно исполняться. Но количество реально параллельно исполняемых потоков в системе ограничено. И ограничено оно количеством вычислительных юнитов процессора. Максимальное число реально параллельных потоков можно узнать с помощью std::thread::hardware_concurrency(). Есть кстати технология HyperThreading от Intel, которая позволяет на одном физическом ядре получить 2 логических, увеличивая таким образом число потенциально параллельных потоков в 2 раза. Ускорения в 2 раза конечно не будет, но тем не менее. "Ну хорошо. Не все 100500 потоков, которые я создал, исполняются параллельно. Не всегда нужно параллельное одновременное исполнение всех тредов. Почему всем не нравится много потоков? Поток - это ресурс, который занимает место и им нужно управлять. Как минимум под каждый поток выделяется свой стек. На десктопах это 1-8 МБ. Поделите количество оперативной памяти на размер стека и получите максимально возможное количество потоков, которое в принципе может поместиться в память. В реальности количество будет еще меньше. Есть же еще затраты на управление потоками. Их же нужно переключать и выдавать каждому подходящий квант времени. Переключает потоки шедулер ядра и на это тоже тратится процессорное время. С ростом количества потоков можно прийти к ситуации, когда затраты на управление потоками будут превышать количество полезной работы, совершаемой потоками. Ну и в некоторых системах явно прописано ограничение на количество потоков. В линуксе, например, можно увидеть заветную числеку с помощью команды cat /proc/sys/kernel/threads-max. Асинхронность - это в первую очередь про асинхронный ввод-вывод. Тут все завязано на событийно ориентированное апи системы и файловые дискрипторы, которых фактически может быть гораздо больше, чем количество потоков. Если у вас есть сервис, который активно работает с сетью - без асинхронности не обойтись. Подход с созданием отдельного потока на каждый запрос перестанет адекватно масштабироваться уже после сотни другой одновременных подключений. Scale up. Stay cool. #concurrency #OS

  • 17 июл.2 9502716

    1 августа Яндекс приглашает разработчиков на бэкенд-конференцию Back to Back Мероприятие пройдёт офлайн и онлайн в Москве, Белграде и Ереване. Обсудим производительность, системную разработку и инженерные задачи на C++, а еще архитектуру и реальные продакшен-задачи. Часть программы в Москве: — Как устроена архитектура аутентификатора в гетерогенной системе с экспертом по разработке ПО YADRO Даниилом Подольским. — Проектирование безопасного механизма отмены для C++20-корутин вместе со старшим разработчиком Яндекса Раедом Романовым. —Доклад о том, что должен знать С++ программист про ABI с Константином Владимировым и Елизаветой Носковой из Syntacore. У каждого города — своя программа. Если собираетесь посетить ивент в Москве, то сможете посетить экспертные сессии 1:1 c разбором личных и карьерных запросов и выступление питерской группы «Научно-технический рэп». Регистрируемся тут.

  • 14 июл.2 9744817

    ​​Зачем нужен деструктор? #новичкам На самом деле даже разработчики с опытом не всегда правильно отвечают на вопрос. Потому что все эти конструкторы и деструкторы крутятся вокруг ресурсов. Но вот каких именно? Давайте разбираться. Несколько раз слышал ответ: "освобождает память, занятую объектом". Следом идет вопрос: - "То есть деструктор деаллоцирует память, на месте которой находится объект?". После положительного ответа я привожу пару примеров: void foo() { std::vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; std::cout << vec.empty(); } Когда в этом случае вызывается деструктор и когда и как происходит деаллокация памяти, занимаемой vec? И еще: void foo() { using ArrType = std::array<int, 5>; auto * ptr = new ArrType{0, 1, 2, 3, 4}; ptr->~ArrType(); } Происходит ли деаллокация динамической памяти в этом случае? Что здесь делает деструктор? На самом деле деструктор просто семантически разрушает объект. Собственно это и есть значение слова "destructor" - разрушитель. На такие темы надо рассуждать с точки зрения лайфтайма объекта. Конструктор создает объект и начинает его время жизни(даже если он ничего не делает). Деструктор же разрушает объект и заканчивает его время жизни. Заметьте, пока про ресурсы ни слова. Будут ли хоть какие-то ресурсы у объекта такого класса? struct A { int i; char c; }; Нет. Хотя у него есть конструктор и деструктор. Да, тривиальные, но есть же. Когда я делаю: void foo() { A obj; } foo(); Вызывается и конструктор, и деструктор(при выходе из скоупа функции). Аллокацию и деаллокацию памяти под obj выполняет сам код, обеспечивающий запуск функции. Разговор о ресурсах начинается тогда, когда в логике объекта заложено обладание ими. std::vector владеет указателем на динамическую память и за счет этого может масштабировать количество элементов в рантайме. Какой-нибудь коннекшен к базе владеет открытым сокетом, через который он может общаться с ней. Только тогда, когда объект действительно единолично владеет каким-то ресурсом, при разрушении объекта нужно этот ресурс освободить. И это не всегда память! Файловые дескрипторы и потоки - это тоже ресурсы и будет плохо, если их не освобождать. Вернемся к примерам из начала поста. void foo() { using ArrType = std::array<int, 5>; // just for explicit destructor call auto * ptr = new ArrType{0, 1, 2, 3, 4}; ptr->~ArrType(); } std::array - это тонкая обертка над сишным массивом. Для его работы не нужны дополнительные ресурсы: его элементы располагаются ровно там, где аллоцировали память под сам объект. Поэтому и деструктор у него ничего не делает. В примере вообще нет деаллокаций динамической памяти, так как в нем отсутствует delete, который и занимается деаллокациями. Теперь другой: void foo() { std::vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; std::cout << vec.empty(); } vec располагается на стеке, поэтому для этого объекта автоматически выделяется и освобождается память, просто за счет увеличения и уменьшения указателя на стек. Так как вектор владеет ресурсом и следует идиоме RAII, динамическая аллокация происходит внутри конструктора, а деаллокация - внутри деструктора. Но эти аллокации не затрагивают память самого объекта vec. Память под объект - это стековая память и ей управляет рантайм. Итого: деструктор нужен для семантического разрушения объекта и заканчивает его время жизни. Деструктор, как приличный джентельмен. должен освободить все ресурсы, которыми объект овладел за время своей жизни. Но он никак не ответственен за деаллокацию памяти под сам объект. Этим занимается либо рантайм, либо программист(явно вызывая delete). Understand the essence. Stay cool. #cppcore

  • 13 июл.2 8023719

    ​​Tricky move #новичкам В прошлом посте специально была допущена определенная неточность. Скомпилировав этот код: struct Example { ~Example() {} }; Example a; Example b; a = std::move(b); Вы не получите ошибку компиляции и все запустится без проблем. Просто код будет работать не совсем так, как вы ожидаете. Вы резонным образом ожидаете, что в последней строчке вызовется присваивание перемещением. Однако вызовется копирующее присваивание. #ЧЗХ? В том же посте мы выяснили, что определение деструктора запрещает генерироваться перемещающим операциям. Но не мешает копирующим! Ну и дальше классика. В С++ если вы ожидаете перемещения чего-либо при вызове std::move - вините свои ожидания, когда все идет по звезде. Результат вызова std::move - rvalue reference aka &&. А правые ссылки спокойно биндятся к const &. И именно такой тип параметра принимает копирующее присваивание. Есть подходящий кандидат для перегрузки, он и вызывается. Еще один прикол на стыке легаси С++ и его модерновой версией. See through tricks. Stay cool. #cpp11

  • 9 июл.3 8313017

    Когда компилятор не сгенерирует 5 специальных методов? #опытным После того, как в С++11 появилась семантика перемещения владения ресурсами, появились также объекты, которые единолично владеют определенным ресурсом. Они настолько не хотят им делиться с другими, что семантика копирования для них стала неприемлемой. Поэтому между новыми(перемещающими) и старыми(копирующими) специальными методами классов появилось некое противоборство - отказ компилятора автоматически генерировать определенные методы при определенных условиях. Вам в любом случае стоит придерживаться правил 0 и 5, когда проектируете интерфейс создания и уничтожения объектов. Но все равно полезно знать, что будет если правила не выполнять. Так когда же компилятор не будет генерировать каждый из специальных методов? Пост в первую очередь про взаимосвязь специальных методов, другие причины упоминаться не будут. 1️⃣ Деструктор Деструктор компилятор может всегда сгенерировать. Оно в целом понятно: объект может как-то создаваться, поэтому и должен уметь как-то уничтожаться. Если вы не делаете ничего экзотического, компилятор предоставит вам деструктор. Другой вопрос, правильно ли он будет работать. Банальный пример: struct Bad { Bad() : p{new int{5}} {} Bad(Bad&& other) { p = other.p; other.p = nullptr; } int * p; }; { Bad b; } // memory leak Очевидно, что мы по коду по-особенному управляем указателем. Но компилятор все равно сам сгенерирует деструктор, который ничего не освободит и мы получим утечку памяти. 2️⃣ Конструктор копирования и копирующий оператор присваивания Если в классе определен хотя бы один перемещающий специальный метод, то ни один копирующий метод не генерируется. struct Example { Example() = default; Example(Example&&) {} }; Example a; Example b = a; // Error: copy ctor is implicitly declared as deleted Example c; c = a; // Error: copy assign operator is implicitly declared as deleted Но это не потому что компилятор такой вредный. Если вы своими ручками определили перемещающие операции, но не определили копирующие, то вы скорее всего и не хотите, чтобы объекты можно было копировать. Но даже если и хотите, то компилятор уже понимает, что поверхностное копирование полей вам не подойдет и просто предостерегает вас от проблем. При этом если вы определили деструктор, то копирующий операции все равно сгенерируются. struct Bad { Bad() : p{new int{5}} {} ~Bad() {delete p;} int * p; }; { Bad b; Bad b1 = b; } // double free Простейший пример и сразу же ловим двойное освобождение. Такое поведение - наследие от более ранних стандартов, когда было правило 3-х, но оно было только на словах. Даже если деструктор делает нетривиальные вещи, то копирующие операции генерировались. Конечно, это unsafe. Но обратная совместимость заставляет С++ нести эти особенности в новые стандарты. 3️⃣ Конструктор перемещения и перемещающее присваивание Это те 2 новых специальных метода, которые добавили в С++11. Для новых вещей стандарт может устанавливать новые условия, которые не несут груз ответственности за обратную совместимость. Поэтому для перемещающих операций все просто: если любой из 4-х оставшихся специальных метода определен пользователем, то компилятор не генерирует данную операцию. struct Example { ~Example() {} // or Example(Example&& other) {} // or Example& operator=(const Example&) {} // or Example& operator=(Example&&) {} }; Example a; Example b; a = std::move(b); // Error: no move assign Оно и понятно: если вы что-то сами определяете, значит хотите чего-то особенного. В этом плане компилятор усиливает правило 5: теперь вы обязаны сами определить перемещающие операций, если определяете другие специальные методы. Be special. Stay cool. #cpp11

  • 8 июл.3 5125030

    Hardening #опытным Раз уж в прошлом посте заикнулись про hardening, давайте разберем его чуть подробнее. Неопределенное поведение (UB) в C++ - самая ужасная категория ошибок. UB может бесшумно повреждать память, вызывать сбои в местах очень отдаленных от фактической ошибки, или, что хуже всего, просто работать на вашей машине долгое время без спецэффектов. Значительная доля UB в реальных кодовых базах происходит не от экзотических языковых функций, а от базового неправильного использования стандартной библиотеки: доступа к вектору за пределами границ, вызова front() на пустом контейнере или вызова метода на пустом std::optional. C++26 частично решает эту проблему напрямую с помощью харденинга стандартной библиотеки Что это за зверь? Харденинг библиотеки преобразует определённое неопределённое поведение в стандартной библиотеке в обнаруживаемые нарушения контрактов во время выполнения. Когда нарушается харденизированное предусловие, среда выполнения реагирует до того, как произойдут какие-либо другие наблюдаемые побочные эффекты. То есть, раньше вся ответственность за корректное использование методов ложилось на плечи программистов. Допускаешь доступ за границы массива - жди беды, тебе о ней компилятор и рантайм не сообщат. Теперь же реализации стандартной библиотеки вставлять специальные проверки, неудовлетворение которой ведет к предсказуемому завершению программы. И вроде как даже предоставляются инструменты для понимания, где произошла "паника". Это не новая идея. Все три основные реализации стандартной библиотеки уже поставляют свои собственные режимы харденинга, зависящие от конкретного вендора. Проблема в том, что эти механизмы различны, непереносимы и не имеют единой спецификации. Теперь это дело стандартизировано. Примеры: std::vector<int> v = {1, 2, 3}; // нарушение контракта: 5 >= 3 int x = v[5]; v.pop_back(); v.pop_back(); v.pop_back(); // нарушение контракта: нельзя убрать элемент из пустого вектора v.pop_back(); std::string_view sv("hello"); // нарушение контракта: 10 >= 5 char c = sv[10]; // нарушение контракта: 10 > 5 sv.remove_prefix(10); std::optional<int> opt; // нарушение контракта: нет реального объекта int x = *opt; int data[10]; // нарушение контракта: разное число элементов // в шаблонном параметре и в аргументе конструктора std::span<int, 5> sp(data, 3); // нарушение контракта: 10 > size() sp.first<10>(); Сильнейший аргумент в пользу этой включения харденинга — производственный опыт Google, на который ссылается пропоузал: применение харденизированного libc++ в «сотнях миллионов строк C++» выявило более 1000 ошибок, включая критически важные для безопасности. Средние накладные расходы на производительность оказались удивительно низкими — 0,30%(одна треть процента). Эти накладные расходы остались такими низкими благодаря способности компилятора устранять избыточные проверки во время оптимизации. Влияние вышло за рамки безопасности: команды наблюдали 30%‑е снижение базового уровня сегментационных ошибок (segfault) в продуктивной среде, что указывает на повышение корректности кода в целом. У gcc и msvc сейчас только частично поддержан hardening, но относительно скоро все мы сможем потратить год на исправление всех найденных уязвимостей насладиться более безопасным кодом. Be safe. Stay cool. #cpp26 #compiler

  • 7 июл.2 9454424

    Такие разные векторы #опытным Про std::vector сказано многое, но он не перестает удивлять. Вектор владеет регионом динамической памяти и ему надо трекать, где заканчиваются текущие элементы и где в принципе заканчивается регион. Наивное представление, которое напишет примерно любой джун: template<typename T> class Vector { ... T * begin; size_t size; size_t capacity; }; Понятно, что в реальности был бы еще аллокатор, тип указателя был бы зависимым типом аллокатора и скорее всего был бы char *, но смысл один: есть указатель на начало и 2 размера: текущее количество элементов и максимально возможное без переаллокаций. Второй вариант - 3 указателя. Начало, конец текущих данных и конец всего стораджа. template<typename T> class Vector { ... T * begin; T * end; T * end_of_storage; }; И именно последний вариант реализован например в GCC. У каждого варианта свои плюсы и минусы: - с помощью 3-х указателей тяжело считать size(), но легко итерироваться и вычислять end(). - указатель и 2 размера легко считают size(), но у них тяжеловато с end(). И в связи с тем, что в С++26 завезли харденинг(рантайм проверки на соблюдение контрактов стандартной библиотеки(например выход за границы массива)) ситуация приобретает интересный поворот. Харденинг неявно увеличивает количество вызовов метода size(). Поэтому становится выгоднее использовать "наивную" реализацию через указатель и 2 числа. Ребята из гугла проверили это на своем софте(у них харденинг проверки уже давно реализованы) и получили буст вплоть до 0.6% перфа на количество обработанных запросов в секунду! Не самого вектора, а самих сервисов. Вот статейка, кому интересно. Крутая статья, кстати. Там в деталях расписано, как внутри std:vector функционирует и еще много всякой полезнятины. Measure your performance. Stay cool. #STL #optimization #cpp26

Грокаем C++ — tgindex