Веб-версияОткрыть в Telegram
ГГрокаем C++

Грокаем C++

✅ Высокое доверие
@grokaemcpp · канал · Технологии · в индексе с 2026-04-21
9 362подписчиков−1 за неделю
2 211средний охват поста
23.6%ER — охват к подписчикам
13постов за 30 дней
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Ответ #опытным 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; позволяет компилятору увидеть метод, принимающий строку, и успешно вызывать его при передаче строкового литерал
24 · 3.9K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
Мапим значения на типы. Ч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 =
30 · 3.6K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
Мапим значения на типы. Ч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 = crea
18 · 3.7K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​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)
15 · 3.6K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Гибридные вектора #опытным std::inplace_vector воплощает в себя преимущества динамического расширения размеров и отсутствия аллокаций. Полезная штука, но и она ограничена. Literally: вы не можете добавить в массив элементов больше, чем capacity. И это нормальные ограничения. Нельзя иметь неограничено расширяемый массив на стеке. Но что если я знаю, что в 99% случаев мой массив будет содержать не больше N элементов. Но все же иногда нужно будет хранить потенциально намного больше элементов. Что делать? Ну для начала есть small object optimization для стандартного std::vector. Вместо того, чтобы хранить элементы в куче, небольшое число маленьких по размеру элементов можно хранить на стеке. И при добавлении элементов больше порога уже использовать динамические аллокации. Как SSO в std::string. Но это не гарантированная оптимизации и зависит от реализации стандартной библиотеки. Плюс невозможно управлять размером этого маленького буфера. Но можно сделать и более управляемый вариант. Пусть будет динамический контейнер, в котором мы гарантировано сможем расположить на стеке N элементов, а при превышении этого порога будет триггериться динамическое выделение памяти под буфер большего размера. Такой комбинированный подход позволяет и рыбку съесть, и на правильный стул сесть. Аллокаций на минимуме, перф суперблейзинговый и привычный интерфейс. Вот несколько представителей этого подхода: - absl::InlinedVector. - boost::container::small_vector. - llvm::SmallVector. - folly::small_vector. Combine advantages. Stay cool. #performance #memory #optimization
15 · 3.5K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Критическая секция. Блок кода #новичкам Термин "критическая секция" имеет 2 значения и это несколько конфузит как новичков, так и опытных разрабов, кто просто не встречался с двумя личинами термина. Сейчас и в следующем посте разберем оба значения. Начнем с простого. Одна из самых больших опасностей многопоточного кода - это гонка данных. Когда 2 потока пытаются читать и записывать в одну ячейку памяти без использования синхронизации. Проблема гонки на самом деле в том, что потоки могут увидеть промежуточное состояние комплексной операции и вклиниться посередине. Даже банальный инкремент инта это 3 операции: чтение, модификация и запись. int counter = 0; // unprotected variable void increment() { for (int i = 0; i < 10000; ++i) { ++counter; // data race } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); // expected 20000, but there is data race so counter will never be eq to the number std::cout << "Final counter value: " << counter << std::endl; return 0; } Но если мы уже будем говорить про такие комплексные операции, как вставка элемента в контейнер, то проблема становится очень явной. Невозможно потокобезопасно, например, проверить превышение size на capacity в векторе, выделить новую память, перенести туда все элементы вектора и создать новый объект. И чтобы между этих операций в контейнер не вмешался другой поток. Чуть более понятный пример: struct Balance { void withdraw_balance(int amount) { if (balance_ >= amount { balance_ -= amount; } } private: int balance_; }; Если 2 потока одновременно зайдут в эту функцию, то оба уменьшат баланс. Мало того, что с клиента снимут больше денег, так он еще и должным может остаться(баланс уйдет в минуса). То есть существуют участки кода, внутри которых происходит доступ к разделяемому ресурсу и там каждый момент времени может исполняться максимум один поток. Такие участки кода на
20 · 3.4K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
Критическая секция. Примитив синхронизации #опытным Стандартный ответ на обычный вопрос "а какие примитивы синхронизации вы знаете?" - мьютексы, атомарные переменные, условные переменные. Для них есть привычные инструменты в языке - std::mutex, std::atomic и std::condition_variable. Иногда проскальзывают в ответах более опытных людей людей семафоры и барьеры - std::counting_semophore и std::barrier. Но очень очень редко кто-то да упомянет критическую секцию. Если вы пишите только на плюсах и кроссплатформенный код, то вряд ли вообще слышали про этот примитив. В стандартной библиотеке нет класса std::critical_section. Тем не менее такой примитив действительно есть, просто в чистом Windows API. По названию понятно, что примитив CRITICAL_SECTION защищает блок кода (критическую секцию в первом значении) от одновременного исполнения более чем одним потоком. Но общепринятый примив для такой ситуации - мьютекс. В чем тогда разница CRITICAL_SECTION и мьютекса? Сейчас мы отходим от языка и погружаемся в системное апи. Оба эти примитива выполняют одинаковую функцию - обеспечивают взаимное исключение потоков. Разница в нюансах Мьютекс Windows API может быть использован для синхронизации между несколькими процессами(если он именованый). CRITICAL_SECTION помогает синхронизировать потоки только внутри одного процесса. CRITICAL_SECTION также чуть лучше перформит за счет того, что перед блокировкой потока там стоит спинлок, который позволяет избежать дорогого обращение к ядру для помещения потока в очередь ожидания. Вот и вся разница. Наиболее близким аналогом CRITICAL_SECTION в линуксе можно считать futex, в котором дорогостоящий системный вызов происходит только, когда несколько потоков прям вот сейчас пытаются войти в защищаемый блок кода. На уровне С++ мы имеем все тот же std::mutex, который скорее всего также использует эту оптимизацию, поэтому в языке про CRITICAL_SECTION ни сном, ни духом. Think critical. Stay cool. #concurrency
19 · 3.4K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Какой тип nullptr? #опытным Для многих nullptr - это магическая хреновина, которая магически и правильно "зануляет"(что бы это не значило) указатель. А как это работает - уже не важно. И даже на этом уровне понимания можно прекрасно писать хороший код и не знать забот. Но мы здесь собрались для грокания С++, поэтому будем разбираться. Для начала, nullptr - это литерал указателя. Есть литералы целочисленые(42, -100), есть литералы строковые("123", "qwerty"), а есть литерал указателя и nullptr - его единственный представитель. Литерал - это prvalue. Этот литерал обозначает константу нулевого указателя. То есть при присваивании указателю nullptr'а, он зануляется. Происходит это потому что существует неявное преобразование nullptr к значению нулевого указателя любого указательного типа. Но это не единственная константа нулевого указателя. Есть также литералы 0 и NULL. У каждого литерала есть свой тип. У nullptr это std::nullptr_t. А у 0 и NULL - int. И этом кроется вся суть: void g(int*) { std::cout << "Function g called\n"; } int main() { g(nullptr); // Fine g(NULL); // Fine g(0); // Fine // But auto null1 = nullptr; auto null2 = NULL; auto null3 = 0; g(null1); // Fine g(null2); // ERROR: non-literal zero cannot be a null pointer constant g(null3); // ERROR: non-literal zero cannot be a null pointer constant } Как только мы присвоили NULL или 0 какой-то переменной, они потеряли способность инициализировать указатели. Потому что они просто стали какой-то переменной типа int, а какими-то рандомными числами очень странно инициализировать указатели. Но с nullptr другая штука. null1 - это переменная типа std::nullptr_t. И здесь нет никакой неоднозначности. Этот тип придуман для того, чтобы представлять нулевой указатель. Он больше ни для чего не нужен. Поэтому также есть неявные преобразование любых значений типа std::nullptr_t к значению нулевого указателя. Это значит, сколько бы раз не был прокинут nullptr
25 · 3.8K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Человеческие сообщения об ошибках в static_assert #опытным Одной неприятной особенностью static_assert еще с С++11 были ограничения на сообщения об ошибках. По сути могли использоваться только строковые литералы. ТО есть просто фиксированные строки. Никаких динамических преобразований: template <class T> void g() { static_assert(sizeof(T) == 4, "Type T size is not 4"); } Из сообщения об ошибке компиляции: error: static assertion failed: Type T size is not 4 мы узнаем лишь то, что размер не равен 4. Хотя, вообще говоря, было бы неплохо увидеть размер переданного типа прямо в сообщении об ошибке для упрощения дебага. Но сделать мы этого не могли, даже константные выражения не могли использоваться. До С++26, когда завезли константные выражения в сообщения об ошибках. После запятой может быть объект msg, у которого должны быть 2 constexpr метода .data() для получения указателя на начало строки и .size() для получения размера. Это позволило использовать различные библиотеки форматирования внутри сообщения об ошибке. Например std::format: template <class T> void f() { static_assert( sizeof(T) == 4, std::format("Type T size should be 4, actual: {}", sizeof(T)) ); } int main() { f<std::int32_t>(); f<std::int64_t>(); } И это работает уже на gcc. Так что внутри static_assert теперь разрешены любые выкрутасы, главное чтобы они были константными выражениями. Be flexible. Stay cool. #cpp26
9 · 3.5K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
Какой метод не переопределяется? #новичкам Для начала. Что будет если не пометить переопределенный в дочернем классе метод как override? Обычно говорят, что override генерирует проверку компилятора, что данный метод действительно переопределяет метод базового класса. И соотвественно ошибку в случае, если это не так. Проверки - это дело важное и нужное, их нужно использовать. Но в том-то и дело, что override - это лишь проверка. Она никак не влияет на то, реально ли переопределяется метод или нет. Взглядните на этот код: struct Base { virtual void foo(int i) const { std::cout << "Base" << std::endl; } virtual ~Base() = default; }; struct Derived : Base { void foo(int) const { std::cout << "Derived" << std::endl; } }; int main() { std::unique_ptr<Base> p = std::make_unique<Derived>(); p->foo(42); } Является ли метод foo в Derived переопределением метода из Base? Казалось бы, нет пометок ни virtual, ни override. Но это и не важно. Главное, чтобы сигнатура метода из дочернего класса была ровно такой же, какой была сигнатура виртуального метода в базовом классе. Здесь с этим все в порядке, поэтому метод действительно переопределяется.. То есть метод не переопределяется только в случае отличной сигнатуры. Поставьте в этом случае override и тогда будет ошибка компиляции. Поставьте в наследнике virtual и сделаете новый метод виртуальный метод в наследнике. Ничего не делаете и получите просто новый невиртуальный метод в наследнике. Что же влияет на сигнатуру? 1️⃣ Имя функции и набор параметров. Это банально и все знают. 2️⃣ CV-квалификация метода. Грубо говоря, константный метод или нет. 3️⃣ Ref-квалификация метода. Говорили о них тут. Если что-то из этого не соответствует виртуальному методу в родительском классе, то дочерний метод не будет переопределять родителя. Use checks. Stay cool. #cppcore
11 · 4K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Ревью #опытным Как-то мы обходили стороной некоторые обновления в стандартной concurrency библиотеке С++. Можно было бы начать прям сразу, но зайдем с небольшого интерактива в рамках рубрики #ревью. Все просто: мы даем небольшой кусочек кода, а вы накидываетесь и оставляете комменты о том, что в этом коде на ваш взгляд не так. Все как в стандартном процессе кодревью. Единственное, надо учитывать формат "небольшого кусочка кода", который тяжело сделать практически полезным. Вот и сам код: #include <atomic> #include <mutex> #include <queue> #include <thread> struct Worker { Worker() : t([](std::stop_source st) { while (!st.stop_requested()) { std::lock_guard<std::mutex> lock(m); q.push(1); } }) {} std::thread t; std::mutex m; std::queue<int> q; }; int main() { Worker w; } Комментатора с самым большим количеством корректно отмеченных проблем упомянем в завтрашнем посте с разбором. Раз, два, три, код в порядок приведи! Critique your decisions. Stay cool.
8 · 3.6K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Результаты ревью #опытным Спасибо всем комментаторам за актив и зоркие глаза! Да, да, ревью - это не только про знания, но еще и про умение замечать все мельчайшие недочеты. Но это лирика, погнали по проблемам. Напомню код: #include <atomic> #include <mutex> #include <queue> #include <thread> struct Worker { Worker() : t([](std::stop_source st) { while (!st.stop_requested()) { std::lock_guard<std::mutex> lock(m); q.push(1); } }) {} std::thread t; std::mutex m; std::queue<int> q; }; int main() { Worker w; } Начнем с простых и дойдем до серьезных: 🔞 Лишний хэдэр, соответственно лишнее время, которое тратится при компиляции на его анализ. 🔞 Внутри коллбэка используются поля класса, при этом захват у лямбды по умолчанию. Эта штука не заработает без захвата this. 🔞 Зачем-то в цикле идет захват лока, хотя никакой реальной конкурентной обработки очереди в данном конкретном случае нет. Его просто можно выкинуть. 🔞 Поток-то мы создали, а завершаться он как будет? std::thread всегда нужно мануально завершать. Но вообще говоря, токены останова сильно намекают на jthread'ы, поэтому можно их использовать и все заведется. 🔞 Коллбэк запуска потоков принимает std::stop_source. Для того, чтобы механизм остановки через стоп-сигналы из С++20 работал, коллбэку std::jthread нужно принимать std::stop_token. 🔞 Ну и основная "нетривиальная" проблема в этом коде. При создании потока в списке инициализации мы захватываем по сути еще неготовый объект. Мьютекс и очередь еще даже по умолчанию не инициализированы. И поток вполне может запуститься со ссылкой на эти неинициализированные данные. А это уже UB. 🔞 В ту же колоду отходит проблема при разрушении объекта. Даже, если мы сделаем jthread и он сможет хоть как-то разрушиться. Вызовы деструкторов полей класса происходят в порядке обратном объявлению. А значит очередь и мьютекс разрушаться до вызова деструктора пото
18 · 3.7K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​2 способа остановки воркеров #опытным Более менее всех опытные плюсовики знают классический подход к graceful остановке воркера. Это атомарный флаг, цикл внутри рабочей функции с проверкой и деструктор с установкой флага. Выглядит все примерно так: class Worker { public: Worker() : t([this] { while (!stop_flag.load(std::memory_order_relaxed)) { std::cout << "Working\n"; std::this_thread::sleep_for(std::chrono::milliseconds(100)); } std::cout << "Stopped\n"; }) {} ~Worker() { stop(); } void stop() { stop_flag.store(true, std::memory_order_relaxed); if (t.joinable()) { t.join(); } } private: std::atomic<bool> stop_flag = false; std::thread t; }; Конечно, там нужно еще куча кода, чтобы заставить воркер полезную работу делать, но сейчас это не важно. Важно, что есть тред и есть отдельная атомарная переменная, сигнализирующая об останове. Но мы(и С++) выросли и появился новый инструмент останова воркера - std::jthread + std::stop_token. Да. std::jthread - это не просто тред, который джойнится в деструкторе. Это тред с вшитой функциональностью остановки. В отличие от std::thread, jthread содержит внутреннее приватное поле типа std::stop_source, которое хранит разделяемое состояние остановки потока. Из этого stop_source можно создать токены std::stop_token, через которые можно мониторить, остановится поток или нет. Грубо говоря, std::stop_source это ручка, за которую можно дернуть и перевести все ассоциированые с ней токены в состояние "остановка запрошена". С токенами можно и отдельно работать, а можно использовать чисто внутри jthread. class Worker { public: Worker() : t([this](std::stop_token st) { while (!st.stop_requested()) { // полезная работа... std::cout << "Working\n"; std::this_thread::sleep_for(std::chrono::milliseconds(100)); } std::cout << "Stopped cleanly\n";
25 · 2.8K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Базовые трейты объектов #новичкам Трейты(или свойства объектов) - мощнейший инструмент проверки требований для исполнения кода. Вы явно в апи через sfinae или концепты можете задавать ограничения на множество типов, с которыми хотите работать, производя проверку в compile-time. Сегодня обсудим для самой простой структуры, какими свойствами классов она удовлетворяет. Чисто, чтобы вкатиться в базовые стандартные трейты. Проверить свойства типа можно довольно просто. static_assert + использование самого трейта: struct A { int a; int b; int c; }; // --- true --- static_assert(std::is_class_v<A>, "A is a class"); static_assert(std::is_aggregate_v<A>, "A is aggregate"); static_assert(std::is_standard_layout_v<A>, "A is standard-layout"); static_assert(std::is_trivially_copyable_v<A>, "A is trivially copyable"); static_assert(std::is_trivially_destructible_v<A>, "A trivially destructible"); static_assert(std::is_trivially_copy_constructible_v<A>, "A trivial copy ctor"); static_assert(std::is_trivially_move_constructible_v<A>, "A trivial move ctor"); static_assert(std::is_trivially_copy_assignable_v<A>, "A trivial copy assign"); static_assert(std::is_trivially_move_assignable_v<A>, "A trivial move assign"); static_assert(std::is_default_constructible_v<A>, "A default constructible"); static_assert(std::is_trivially_default_constructible_v<A>, "A trivial default ctor"); static_assert(std::is_object_v<A>, "A is object"); static_assert(std::is_compound_v<A>, "A is compound"); // --- false --- static_assert(!std::is_empty_v<A>, "A is NOT empty (has members)"); static_assert(!std::is_polymorphic_v<A>, "A is NOT polymorphic"); static_assert(!std::is_fundamental_v<A>, "A is NOT fundamental"); static_assert(!std::is_scalar_v<A>, "A is NOT scalar"); Вообще эти трейты - это некая шаблонные структуры. У них есть статический constexpr член value, который обращается в true, если свойство присутствует у типа, и в false если нет. Для каждого трейта опре
31 · 2.7K ·
Г
Грокаем C++
Базовые трейты объектов. Ч2 #новичкам В прошлый раз у нас была довольно простая С-like структура, для которой мы смотрели стандартные свойства. struct A { int a; int b; int c; }; Сегодня посмотрим, какие свойства типа изменятся, если совсем чуть-чуть изменить структуру. 🔀 Добавим просто std::string в качестве поля структуры. struct A { int a; int b; int c; std::string s; }; Из-за того, что у std::string нетривиальные специальное методы классов, то это автоматически делает нетривиальными специальные методы у A, поэтому отличие будет только в следующих трейтах: static_assert(!std::is_trivially_copyable_v<A>); static_assert(!std::is_trivially_destructible_v<A>); static_assert(!std::is_trivially_copy_constructible_v<A>); static_assert(!std::is_trivially_move_constructible_v<A>); static_assert(!std::is_trivially_copy_assignable_v<A>); static_assert(!std::is_trivially_move_assignable_v<A>); static_assert(!std::is_trivially_default_constructible_v<A>); 🔀 Добавим виртуальный метод struct A { int a; int b; int c; virtual void foo() {} }; Очевидно, тип сразу же стал полиморфным. Но и много чего потерял. Чисто по определению некоторых свойств полиморфный тип не может им удовлетворять. Это: 👉🏿 std::is_standard_layout - грубо говоря, тип становится невместим с С 👉🏿 std::is_trivially_copyable - тривиальное копирование подразумевает простое копирование по байтам. Копирование полиморфного типа нельзя производить побайтово. Компилятор обязан либо скопировать vptr как есть (если это тот же тип), либо установить правильный vptr нового типа (если копируем через базовый класс — slicing). Это уже нетривиальная логика. 👉🏿 std::is_trivially_default_constructible - конструктор по умолчанию не тривиальный, так как надо инициализировать vptr в правильную vtable. 👉🏿 std::is_aggregate - как только у класса появляется приватный метод(vptr в данном случае), он уже не может быть агрегатом. static_assert(std::is_polymorphic_v<Poly>); stati
19 · 2.2K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Безопасный memcpy #новичкам memcpy — это один из самых быстрых способов скопировать данные и один из самых быстрых способов получить UB. Функция принимает void* и size_t и радостно съест всё, что вы ей дадите: полиморфный тип, std::string, объект с нетривиальным деструктором. Компилятор не скажет ни слова, а программа сломается в самом неожиданном месте. В прошлых постах мы поговорили о трейтах. Для заданного типа они определяют, обладает ли тип определенным свойством или нет. Давайте применим трейты на практике. Ограничим через них доступ к самописной обертке memcpy. И в этом нам помогут концепты. Если трейты - это свойство типа, то концепт - это требование для типа. Определим концепт, который требует, чтобы тип был объектом(не функцией и не ссылкой) и был тривиально копируемым: template <typename T>  concept SafeForMemcpy = std::is_object_v<T> && std::is_trivially_copyable_v<T>; И вот теперь мы говорим в шаблонном функции, что хотим потребовать от типа выполнения условия SafeForMemcpy. Это можно сделать разными способами, вот самый простой: template <SafeForMemcpy T> void safe_memcpy(T* dest, const T* src) { static_assert(sizeof(T) > 0, "Cannot copy incomplete type"); std::memcpy(dest, src, sizeof(T)); } Вместо ключевых слов typename или class используется имя концепта. Теперь при использовании нетривиально копируемых типов будет появляться ошибка компиляции с нормальным сообщением о том, что тип не удовлетворяет концепту. В будущих постах будет разбирать концепты чуть подробнее. Require the best conditions. Stay cool. #cpp20 #template
20 · 2.4K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​std::find vs std::ranges::find #опытным С++20 библиотека диапазонов принесла нам много новых полезных алгоритмов и способов управления данными. Но не только это. Рэнджи задублировали и привычные нам "древние" стандартные алгоритмы. Хотя кажется "что какая разница, использовать новое апи или старое?" - разница все-таки есть. И сегодня поговорим об одном конкретном кейсе. Что делает std::find? Ищет элемент в промежутке между итератором начала и конца последовательности и возвращает на него итератор на этот элемент: auto it = std::find(vec.begin(), vec.end(), 42); Если элемент не найден, то find просто возвращает итератор на конец диапазона. Ровно такую же строчку можно написать с std::ranges::find и это заработает: auto it = std::ranges::find(vec.begin(), vec.end(), 42); Однако здесь открываются новые возможности. Все потому, что второй аргумент алгоритм - конец диапазона - не обязан быть того же типа, что и итератор начала: // std::find template< class InputIt, class T > InputIt find( InputIt first, InputIt last, const T& value ); // std::ranges::find template< std::input_iterator I, std::sentinel_for<I> S, class T, class Proj = std::identity > requires std::indirect_binary_predicate <ranges::equal_to, std::projected<I, Proj>, const T*> constexpr I find( I first, S last, const T& value, Proj proj = {} ); По шаблонным параметрам видно, что во втором случае first и last могут быть разных типов, в отличие от std::find. Это позволяет использовать кастомный тип ограничителя последовательности, который позволит сделать поиск несколько эффективнее. std::find реализован примерно вот так: template<class InputIt, class T = typename std::iterator_traits<InputIt>::value_type> constexpr InputIt find(InputIt first, InputIt last, const T& value) { for (; first != last; ++first) if (*first == value) return first; return last; } На каждой итерации цикла идет проверка, не дошли ли мы до конца. Это нужно,
12 · 2.7K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Виртуальный шаблонный метод #опытным Во влажных мечтах С++ программистов всегда находится место для возможностей одновременного использования статического и динамического полиморфизмов. И тут прям напрашивается иметь виртуальный шаблонный метод. Давайте напишем: struct Base { template <typename T> virtual void do_something(T arg) = 0; virtual ~Base() = default; }; struct Derived : Base { template <typename T> void do_something(T arg) override { // do something } }; Выглядит привычно, пробуем скомпилировать.... И оно не билдится. С++ не разрешает вам иметь виртуальный шаблонный метод. Но почему? Надо понимать, как под капотом работают виртуальные и шаблонные функции. При определении шаблона, никакой низкоуровневый код не генерируется. Код генерируется только, когда компилятор видит использование шаблона с каким-то конкретным типом. Подробнее можно тут почитать. Так вот реальный код инстанциаций шаблона может быть раскидан по очень большому множеству единиц трансляции. Теперь про виртуальные функции. В абсолютном большинстве компиляторов они реализованы через vtable. Подробнее тут. Но вкратце: во время компиляции класса с виртуальным формируется статический массив, состоящий из адресов скомпилированных виртуальных методов. Порядок нахождения в массиве определяется порядком объявления методов в классе. Почему вообще возможна такая генерация? Потому что компилятор уже в этой конкретной единице трансляции знает весь набор виртуальных методов в базовом классе и в наследнике. Поэтому он заранее может выбрать правильный размер vtable. Теперь пытаемся совместить. Для генерации vtable на этапе компиляции нам нужно знать весь набор виртульных методов. И это прямо противоречит тому факту, что шаблон ничего не знает о своих потенциальных инстанциациях, которые разбросаны по куче единиц трансляции. Каждая единица трансляции компилируется независимо, поэтому получить информацию об адресах конкретных инстанциаций из других TU невоз
17 · 2.5K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​std::source_location #новичкам Если вы до сих пор не знали, как платформонезависимо(безо всяких PRETTY_FUNCTION) получать человеческий доступ к имени файла, имени функции и строчке кода, где вы сейчас находитесь, то пост для вас. В С++20 появился класс std::source_location, который инкапсулирует в себе информацию о точке исходного кода, где был создан соответствующий объект. Применение до боли простое. У класса есть набор методов, которые в сумме и дают понимание о том, где был создан объект. Вы просто создаете объект через единственный статический метод std::source_location::current и получаете, что нужно. Пример: #include <iostream> #include <source_location> #include <string_view> void log(const std::string_view message, const std::source_location location = std::source_location::current()) { std::clog << "file: " << location.file_name() << '(' << location.line() << ':' << location.column() << ") `" << location.function_name() << "`: " << message << '\n'; } int main() { log("Hello world!"); } В момент вызова функции log компилятор должен вычислить все параметры, переданные в функцию. Поэтому параметр location с дефолтным параметром std::source_location::current() как раз и делает нужный трюк - location неявно присваивается расположение вызова функции в коде всякий раз, когда кто-то ее дергает. После чего нужно сериализовать location через вызовы методов в нужном порядке и вуаля, красивый лог: file: /app/example.cpp(17:8) int main(): Hello world! В больших фреймворках, типа userver вам конечно же не нужно о таком беспокоиться. Но если вы пилите что-то свое кастомное, то std::source_location - хорошая тула для дебаггинга и логирования. Define your place. Stay cool. #cpp20
19 · 2.1K ·
Грокаем C++
Агенты добрались до продакшена Про AI-агентов сейчас не говорит только ленивый. Обычно это выглядит так: «мы прикрутили LLM, дали ей пару тулов, теперь она сама все делает». А потом это пытаются выкатить в прод, и внезапно оказывается, что агент — это не чатик с промптом, а распределенная система с дорогущими GPU, долгими запросами, динамическим графом исполнения, ретраями, которые нельзя просто так делать, и метриками качества, которые надо в принципе уметь измерять и делать это каждый день. На недавнем deep tech night прошел показательный доклад «От поиска к агентам: путь Алисы», который я глянул. Круто, когда говорят не про то, как ллм и агенты делают космолеты, а какие вызовы несет в себе их использование и как сделать работу с языковыми моделями надежной, отслеживаемой и универсальной в рамках большой компании. Изыскания ребят привели их к AGTS - агентской транспортной системе. Если коротко, это сервис-медиатор между агентами, тулами и моделями. Все общаются не напрямую друг с другом, а через AGTS. И вот тут начинается нормальный backend: 🔸 агенты и тулы могут быть написаны на разных языках — Python, C++, Java/Kotlin, Go; 🔸 можно переиспользовать одну инфраструктуру для прода и приемки качества; 🔸 есть «железные пользователи» — агенты, которые имитируют пользовательское поведение; 🔸 LLM-as-a-judge - модели оценки качества "услуг агентов" - тоже живут как агенты на той же платформе; 🔸 AGTS кэширует запросы и ответы к моделям и тулам, чтобы при сбое не перезапускать всю дорогую LLM-мясорубку с нуля. Последний пункт особенно важен. Агент упал не в самом начале, а где-нибудь на пятой минуте размышлений? Не надо снова жечь GPU и молиться. Система делает fast-forward по закэшированным данным до точки падения и продолжает оттуда. Пожалуй один из главных поинтов доклада: harness > model. То есть качество можно серьезно растить не только заменой модели на новую большую и дорогую, а обвязкой вокруг нее. И эти обвязки централизовано добавляются и управляютс
20 · 2.4K ·
Г
Ссылка
нажмите — покажем
​​Identity объекта #опытным На пространстве интернетов мне попался шортс, где довольно опытный в С++ чувак рассказывает про некий "трюк" в контексте такой задачи: По сути, нужно построить центральный сервис, который отправляет обновления цен на станции, но новые желаемые цены могут поступать, пока старые обновления ещё в пути. Вас волнует только то, чтобы каждая станция в конечном итоге получила самую последнюю желаемую цену, при этом подтверждения (acknowledgements) должны приходить по порядку, а лишние обновления следует избегать. Таким образом, обновления, которые вы отправляете, могут прибывать на станцию не по порядку, но подтверждения, которые вы получаете обратно, всегда должны быть правильно упорядочены в том порядке, в котором станция применила обновление цены. И, по сути, спроектировать класс так, чтобы минимизировать время и тд. трюк в том, что если вам нужен идентификатор объекта, то вы можете использовать его уникальный адрес. Приводился такой пример: struct StationClient { [[nodiscard]] uintptr_t ClientId() const { return reinterpret_cast<uintptr_t>(this); } }; Выглядит с первого взгляда круто. Действительно, у каждого объекта свой уникальный адрес. Два объекта в программе не могут занимать одну ячейку памяти. Получается, что адрес можно использовать как id и это "просто работает". Только вот сходу можно назвать кучу проблем, к которому ведет этот подход: 🔞 id объекта меняется, если он перемещается 🔞 id объекта меняется, если он копируется(должны ли такие объекты копироваться и перемещать вообще - это вопрос, но проблемы остаются) 🔞 такой айди тесно связан с временем жизни объекта и не существует о отрыве объекта в программе 🔞 id переиспользуется другими объектами и на стеке, и на куче. Как только разрушился объект, новый объект может получить тот же самый id. И как различить объекты в таком случае непонятно. 🔞 id нельзя безопасно использовать как внешний идентификатор, аля передавать в другие сервисы или сохранять в хранили
14 · 2.4K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​std::spanstream #опытным Радостная весть для всех, кто пользуется iostreams! В C++23 добавлены новые классы, позволяющие работать с массивами символов как c потоками, без лишних копирований и динамических выделений памяти. Пусть у нас есть какой-то сырой буфер данных и мы хотим безопасно считать его с удобным и привычным iostream'овским апи, при этом не делая никаких копий. Для этого ввели std::ispanstream. Он принимает С++20 std::span в качестве буфера и сам не производит никаких аллокаций и копирований: char input[] = "10 20 30"; std::ispanstream is{span<char>{input}}; int i; is >> i; ASSERT_EQUAL(10,i); is >> i; ASSERT_EQUAL(20,i); is >> i; ASSERT_EQUAL(30,i); is >> i; std::ispanstream может работать с любыми символами, расположенными последовательно в памяти (другими словами, со всем, от чего можно сконструировать std::span). Для чтения данных фиксированного размера std::ispanstream фактически заменяет std::istringstream, потому что гарантирует отсутствие аллокаций. В общем, если вы как-то получили доступ к непрерывному сырому участку памяти и вам нужен удобный доступ к парсингу - std::ispanstream вам подойдет. Можно даже парсить данные из файла, если использовать mmap и замаппить данные файла в виртуальное пространство процесса. Тогда можно получить std::span на данные этого файла и создать std::ispanstream, парсящий их. Правда, если вы уже используете mmap, то скорее всего хотите какое-то производительное решение. А парсер стандартных потоков ввода-вывода мягко говоря не самый быстрый. Тот же std::from_chars будет быстрее и не менее стандартным. Don't allocate. Stay cool. #cpp23
22 · 2.2K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Stacktrace #опытным Одна из проблема исключений - непонятно, откуда оно прилетело. Ну да, по сообщению об ошибке и типу исключения обычно можно отследить нужное место в программе. Но как исполнение дошло до этой точки? А это ведь самое важное - понимание, при каком сценарии наступила исключительная ситуация. Когда мы дебажим программу, мы видим, что привело к точке останова. В любом дебаггере есть команда аля backtrace, которая выводит визуализированный стек вызовов. Например так: (gdb) bt #0 0x0000555555556a2a in apply_discount (order_id=1001, total=250.5) at discount.c:45 #1 0x0000555555556b10 in calculate_final_price (order=0x7fffffffde40) at order_engine.c:89 #2 0x0000555555556c45 in process_payment (order=0x7fffffffde40, method=0x555555758020 "credit") at payment.c:120 #3 0x0000555555556d8a in handle_order_request (request=0x7fffffffdf00) at api_handler.c:56 #4 0x0000555555556e20 in main () at server.c:32 В данном случае мы видим, что ошибка произошла в функции apply_discount, которую вызвал калькулятор цены при обработке запроса платежа. То есть в данном случае мы видим четкий сценарий возникновения ошибки И вот было бы круто видеть такую картину каждый раз, когда в catch залетает исключение. Да и в принципе в любой ситуации, когда нам интересна информация из трейса. С++23 предоставил нам возможность конструировать кастомные исключения с описанием стека вызова внутри. Делается это через класс std::stacktrace. Работает он очень просто: #include <iostream> #include <stacktrace> std::stacktrace bar() { auto trace = std::stacktrace::current(); return trace; } std::stacktrace foo() { return bar(); } int main() { auto trace = foo(); std::cout << trace << '\n'; } С помощью статического метода std::stacktrace::current() можно получить стек вызовов в моменте использования метода. Для типа std::stacktrace даже перегружен оператор вывода в поток, поэтому вы спокойно можете вывести сырой трейс на консоль. Вывод может быть примерно т
22 · 1.6K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
​​Stacktrace. Tips #опытным Чтобы полноценно работать со стандартными трейсами, нужно знать несколько вещей: ✅ Чтобы фича заработала с новыми версиями gcc и clang, вам нужно залинковать либу -lstdc++exp. В туториалах обычно используется линковка с -lstdc++_libbacktrace, которая не работает на свежих версиях компиляторов ✅ Собирайте код с дебаг символами. Если хотите видеть трейсы и получать из них полезную информацию об именах функций и файлов, вместо прочтения чистых адресов, то надо собирать в дебаге. Итого полная команда сборки: g++ -std=c++23 -g -lstdc++exp -O2 main.cpp -o app ✅ Да, вы можете включать оптимизации компилятора и добавить в бинарник дебаг символы одновременно. Оптимизации конечно могу сильно преобразовывать потенциальные трейсы за счет инлайнинга, но без оптимизаций мы не можем. ✅ Дебаг символы обычно занимают довольно внушительный объем и не всегда хочется их тянуть везде вместе с бинарем. В таких случаях можно использовать утилиту strip и вырезать из бинаря отдельный файл с дебаг символами. При обнаружении проблемы с программой можно в дебаггере подгрузить файл с символами и наслаждаться прелестями человекочитаемой отладки. Ну или можно собирать код с флагом -g1, который включает только самую необходимую отладочную информацию ✅ Мы рассматривали стектрейсы в разрезе исключений, но их конечно же можно использовать и отдельно от них. int foo(int a, int b) { if (b == 0) { LOG_ERROR << "Second operand is zero. Trace:\n" << std::stacktrace::current(); } return a / b; } Однако стоит помнить, что получение трейса - не бесплатная штука. Логи-то не бесплатный, а проход по стеку вызовов и сбор информации из дебаг символов - очень затратная штука. Не стоит использовать трейсы в нагруженных частях программы. Только в ошибочных путях программы в некритичном коде. ✅ Сам класс стектрейсов предоставляет много гибкости. Можно отдельно проходиться по каждому фрейму и в принципе контролировать количество захватываемых фреймов: void i
18 · 1.1K ·
Г
Грокаем C++
Ссылка
нажмите — покажем
Откуда spurious wakeup на кондваре? #опытным У кондваров есть метод std::condition_variable::wait, который по документации блокирует исполнение потока до тех пор, пока не придет уведомление или не произойдет тн spurious wakeup(ложное пробуждение). Поэтому нам говорят, что нужно ставить wait в цикл while: while (!pred()) cv.wait(lock); или использовать перегрузку, передавая в кондвар предикат: cv.wait(lock, pred); Но что это за такие ложные пробуждения, из-за которых на нужно крутиться в цикле? По сути, ложными пробуждениями можно назвать любую ситуацию, когда поток просыпается и условие предиката неудовлетворяется. Со стороны кажется, что поток просто в рандомный момент сам проснулся, когда условия еще не создались для него. Но это не совсем так. Давайте покопаемся в причинах. 👉🏿 Поток может проснуться без уведомления. Звучит как баг или признаки рождения скайнета, но все прозаичнее. На реальных системах есть свои реальные причины такого поведения. В линуксе раньше для управления потоками(запуск, остановка и тд) система использовала сигналы. В такой модели условная переменная физически могла быть разбужена сигналом, предназначенным для совершенно другой цели — например, менеджер потоков решил передать потоку команду, а сигнал попал в тот же обработчик, что и пробуждение от cond_signal. POSIX стандарт должен был учитывать эту особенность, поэтому прям в доке прописал возможность пробуждения без сигнала. Свежих версиях линукса такой ситуации уже не может быть и из pthread'ной функции ожидания cv управление не вернется в юзерспейс при отправке любых сигналов. Но наследие осталось. 👉🏿 А вот тут уже реальная проблема с race condition. Поток реально получил сигнал на пробуждение, проснулся, начал выполняться и увидел, что условие ложно. Потому что по сути какой-то другой поток уже успел обработать появившееся событие. Это может произойти по многим причинам, но может быть вот такая ситуация: 1. Поток A спит на cond_wait. 2. Поток B меняет условие и выз
11 · 955 ·

Открытая публичная лента из поискового индекса ChatCrawler — «Google по публичному Telegram»; обновляется по мере обхода площадки. Время — UTC.

Только публичный контент, официальный API Telegram. О проекте · Вопросы · Чего мы не делаем · Убрать страницу из выдачи · Каталог · Поиск · Как мы считаем