Грокаем C++
описание
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов. По всем вопросам (+ реклама) @ninjatelegramm Менеджер: @Spiral_Yuri Реклама: https://telega.in/c/grokaemcpp Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Лучшие посты
за три месяцабез подписи
без подписи
без подписи
Ответ на квиз #новичкам Напомню код: 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
без подписи
без подписи
Когда компилятор не сгенерирует 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
без подписи
Есть ли в С++ отрицательный ноль? #опытным Вопрос странный, но тем не менее проверяет кучу вещей: ваши знания базы, подкапотных механизмов и изменений в стандартах. Поехали Какие у нас вообще числа есть? Знаковые и беззнаковые целые, а также вещественные. Начнем с простого. В беззнаковых числах не может быть отрицательного нуля, очевидно. А вот со знаковыми уже начинаются вопросы. Да, в матеше ноль в целых числах представлен в единственном экземпляре. А компьютеры не всегда могут эмулировать в точности математические концепции. Про целые числа у нас есть серия постов: затравка и начало серии. Так вот С++ разрешал использование методов обратного кода и знак-амплитуда для представления отрицательных чисел. И у них есть положительный и отрицательный ноль. Но в С++20 стандарте четко зафиксировали использование дополнительного кода, в котором только один ноль. А что с вещественными числами? Тут вообще зоопарк. Стандарт говорит, что формат вещественных чисел задается компилятором по своему усмотрению. Но большинство реализаций выбирают IEEE 754. Там вещественное число задается тремя параметрами: знак, экспонента и мантисса. Значение можно вычислить по такой формуле: value=(−1)^S × 2^E × (1+M) Ноль задается нулевой мантиссой и экспонентой. Но у нас же есть и знаковый бит еще. Поэтому на большинстве платформах в С++ есть отрицательный вещественный ноль. Кстати наряду с положительными и отрицательными бесконечностями. Be positive. Stay cool. #compiler #base #cpp20
Сколько процессов можно запустить на одной машине? #опытным Симметричный вопрос относительно предыдущего поста, который затрагивает важные различия ОС. В 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
без подписи
Квиз #новичкам Очень простой #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.
Правило 0 #новичкам Правило 5 говорит нам определять все 5 специальных методов, если нам нужно определить хотя бы 1 из них. Но как часто вам сейчас реально нужно определять специальные методы? Возможно в каком-то библиотечном или фреймворкочном коде это встречается почаще. Но чем выше уровень абстракций в коде, тем ниже вероятность встретить определение специальных методов. Почему? Язык уже сейчас богат на различные средства управления ресурсами. Есть стандартные контейнеры, есть умные указатели. Они уже внутри себя определяют семантику владением ресурсом и предоставляют простой интерфейс для работы с ними. Нам не обязательно писать все руками(если не нужно выжимать микросекунды и килобайты памяти). Для управления ресурсами можно пользоваться готовыми инструментами и сфокусироваться на логике приложения. Именно об этом и говорит правило 0. Если вы пользуетесь обертками управления ресурсами и вас устраивает дефолтное поведение компилятора при операциях с объектами, то вам не нужно определять ни одного специального метода. class rule_of_zero { std::string cppstring; public: rule_of_zero(const std::string& arg) : cppstring(arg) {} }; // std::string will manage resource Однако иногда есть один интересный кейс. Пускай у вас есть полиморфный класс. Да, вы там используете все возможные обертки и хотите использовать правило 0. Но у вас не выйдет. Как минимум вы определите виртуальный деструктор и пометите его как default. То есть уже не 0. И только этот факт вас уже должен насторожить. Потому что могут быть скрытые проблемы. Например, может произойти случайный слайсинг полиморфного объекта при передаче по значению. Чтобы избежать неприятностей, вам нужно удалить мув и копи операции. class Base { public: virtual ~Base() = default; Base(const Base&) = delete; Base& operator=(const Base&) = delete; Base(Base&&) = delete; Base& operator=(Base&&) = delete; // ... other constructors and functions ... }; В этом случае вы попали четко в правило 5: даже такая синтаксическая необходимость дефолтирования виртульного деструктора должно вам настрожить, подумать о последствиях и в итоге прийти к определению всех 5 методов. Follow the rules. Stay cool. #design #goodpractice
без подписи
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
без подписи
Cколько динамических аллокаций будет в коде из поста выше?
без подписи
без подписи
без подписи