Грокаем C++
описание
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов. По всем вопросам (+ реклама) @ninjatelegramm Менеджер: @Spiral_Yuri Реклама: https://telega.in/c/grokaemcpp Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
9 326
подписчиков
Охват к подписчикам
32,0%
ERR
Реакции к просмотрам
1,17%
2 020 на 50 постов
Пересылки к просмотрам
0,59%
1 016
Постов в день
0,3
всего 289
Где отзываются чаще
доля реакций к просмотрам- 15 авг.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 #STL3,14%
- 5 авг.Ответ #опытным 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 #cppcore2,14%
- 28 апр.без подписи2,11%
- 9 авг.Мапим значения на типы. Ч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.1,87%
- 12 июн.без подписи1,67%
- 2 авг.Оверхэд системных вызовов #опытным Как измерить оверхэд системного вызова? Задача ведь не самая тривиальная: сискол делает разную полезную работу, которая потенциально занимает сильно больше времени, чем переход в режим ядра и обратно. И вставить свой измеряющий код в место начала и конца логики сискола мы тоже не можем - это часть скрыта от нас и вообще написана на ассемблере. Что делать? Брать самый легковесный сискол. Который делает минимальную работу. Список сисколов можно найти в 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 #OS1,65%
- 14 июл.Зачем нужен деструктор? #новичкам На самом деле даже разработчики с опытом не всегда правильно отвечают на вопрос. Потому что все эти конструкторы и деструкторы крутятся вокруг ресурсов. Но вот каких именно? Давайте разбираться. Несколько раз слышал ответ: "освобождает память, занятую объектом". Следом идет вопрос: - "То есть деструктор деаллоцирует память, на месте которой находится объект?". После положительного ответа я привожу пару примеров: 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. #cppcore1,61%
- 30 июл.Что происходит при системном вызове? #опытным Существует строгая граница между приложениями, которые вы запускаете, и ядром компьютера (аппаратным обеспечением и ядром ОС). Зачем она нужна? Пользователям нельзя доверять. Нужно оградить процессы, чтобы они не пострадали от действий другого процесса(написанного говнокодером или хакером). Системный вызов является фундаментальным интерфейсом между приложением и ядром системы. Только через него пользователи могут запросить у системы выполнение какой-то задачи. Для большинства сисколы - это просто хэндлеры, которые они дергают, чтобы получить нужный результат. Но там много интересностей происходит под капотом и именно об этом пойдет сегодня речь. Будем все рассматривать на примере 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 #howitworks1,51%
- 7 июл.Такие разные векторы #опытным Про 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 #cpp261,49%
- 19 июн.без подписи1,45%
- 8 июл.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 #compiler1,42%
- 17 июн.без подписи1,39%