Грокаем C++
7.55K subscribers
25 photos
3 files
340 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Представление отрицательных чисел в С++

Отрицательные числа - заноза в заднице компьютеров. В нашем простом мире мы различаем положительные и отрицательные числа с помощью знака. Положительные числа состоят просто из набора цифр(ну и по желанию можно добавить + слева, никто не обидится), а к отрицательным слева приписывается минус. Но компьютеры у нас имеют бинарную логику, там все представляется в виде единиц и ноликов. И там нет никаких плюсов-минусов. Тогда если у нас модуль числа уже представляется в виде единичек и ноликов, то непонятно, как туда впихнуть минус.

Вот и в комитете по стандартизации не знали, как лучше это сделать и удовлетворить всем, поэтому до С++20 они скидывали с себя этот головняк. До этого момента С++ стандарт разрешал любое представление знаковых целых чисел. Главное, чтобы соблюдались минимальные гарантии. А именно: минимальный гарантированный диапазон N-битных знаковых целых чисел был [-2^(N-1) + 1; 2^(N-1)-1]. Например, для восьмибитных чисел рендж был бы от -127 до 127. Это соответствовало трем самым распространенным способам представления отрицательных чисел: обратному коду, дополнительному коду и метод "знак-величина".

Однако все адекватные компиляторы современности юзают дополнительный код. Поэтому, начиная с С++20, он стал единственным стандартным способом представления знаковых целых чисел с минимальным гарантированным диапазоном N-битных знаковых целых чисел [-2^(N-1); 2^(N-1)-1]. Так для наших любимых восьмибитных чисел рендж стал от -128 до 127.

Кстати для восьмибитных чисел обратной код и метод "знак-амплитуда" были запрещены уже начиная с С++11. Все из-за того, что в этом стандарте сделали так, чтобы все строковые литералы UTF-8 могли быть представлены с помощью типа char. Но есть один краевой случай, когда один из юнитов кода UTF-8 равен 0x80. Это число не может быть представлен знаковым чаром, для которого используются обратной код и метод "знак-величина". Поэтому комитет просто сказал "запретить".

Для кого-то много непонятных слов и терминов, поэтому в дальнейшем будем раскрывать все секреты представления отрицательных чисел в памяти.

Stay defined. Stay cool.

#cppcore #cpp20 #cpp11
​​Знак-амплитуда

Начнем раскрывать тему вариантов представления отрицательных чисел с метода "знак-величина" или sign-magnitude. Это самый интуитивно понятный для нас способ репрезентации целых чисел. В обычной математике как: есть грубо говоря модуль числа и его знак.

И это довольно просто перенести в память. Мы просто обговариваем, что наиболее значимый бит числа мы отводим по бит знака, а остальные биты обозначают модуль числа. Если знаковый бит - 0, значит число положительное. Если 1 - отрицательное. Таким образом получается вполне естественная ассоциация с привычными нам правилами. Есть натуральный ряд чисел плюс ноль. Чтобы получить множество всех целых чисел, нужно ко множеству натуральных чисел прибавить множество чисел, которое получается из натуральных путем добавления минуса в начале числа.

Давайте на примере. В восьмибитном числе только 7 бит будут описывать модуль числа, который может находится в отрезке [0, 127]. А самый старший бит будет отведен для знака. С добавлением знака мы теперь может кодировать все числа от -127 до 127. Так, число -43 будет выглядеть как 10101011, в то время как 43 будет выглядеть как 00101011. Однако очень внимательные читатели уже догадались, что эта форма представления отрицательных чисел имеет некоторые side-effect'ы, которые усложняют внедрение этого способа в реальные архитектуры:

1️⃣ Появление двух способов изображения нуля: 00000000 и 10000000.

2️⃣ Представление числа из определения разбивается на 2 части, каждая из которых должна иметься в виду и каким-то образом обрабатываться.

3️⃣ Из предыдущего пункта выходит, что операции сложения и вычитания чисел с таким представлением требуют разной логики в зависимости от знакового бита. Мы должны в начале "отрезать" знаковый бит, сделать операцию, которая соответствует комбинации знаковых битов операндов и первоначальной операции, и потом обратно совместить результат со знаком.

4️⃣ Раз у нас 2 нуля, мы можем представлять на 1 число меньше, чем могли бы в идеале.

В общем, минусы значительные. И хоть такое представление и использовалось в ранних компьютерах из-за интуитивной понятности для инженеров, однако в наше время этот способ представления знаковых целых чисел практически не используется.

Однако "знак-величина" используется по сей день, но немного для другого. IEEE floating point использует этот метод для отображения мантиссы числа. Есть модуль мантиссы(ее абсолютное значение) и есть ее знаковый бит. Кстати поэтому у нас есть положительные и отрицательные нули, бесконечности и NaN'ы во флотах. Вот как оно оказывается работает.

Apply yourself in the proper place. Stay cool.

#cppcore #base
​​Целочисленные переполнения

Переполнения интегральных типов - одна из самых частых проблем при написании кода, наряду с выходом за границу массива и попыткой записи по нулевому указателю. Поэтому важно знать, как это происходит и какие гарантии при этом нам дает стандарт.

Для беззнаковых типов тут довольно просто. Переполнение переменных этих типов нельзя в полной мере назвать переполнением, потому что для них все операции происходят по модулю 2^N. При "переполнении" беззнакового числа происходит его уменьшение с помощью деления по модулю числа, которое на 1 больше максимально доступного значения данного типа(то есть 2^N, N - количество доступных разрядов). Но это скорее не математическая операция настоящего деления по модулю, а следствие ограниченного размера ячейки памяти. Чтобы было понятно, сразу приведу пример.

Вот у нас есть число UINT32_MAX. Его бинарное представление - 32 единички. Больше просто не влезет. Дальше мы пробуем прибавить к нему единичку. Чистая и незапятнанная плотью компьютеров математика говорит нам, что в результате должно получится число, которое состоит из единички и 32 нулей. Но у нас в распоряжении всего 32 бита. Поэтому верхушка просто отрезается и остаются только нолики.

Захотим мы допустим пятерку, бинарное представление которой это 101, прибавить к UINT32_MAX. Произойдет опять переполнение. В начале мы берем младший разряд 5-ки и складываем его с UINT32_MAX и уже переполненяемся, получаем ноль. Осталось прибавить 100 в двоичном виде к нолю и получим 4. Как и полагается.

И здесь поведение определенное, известное и стандартное. На него можно положиться.

Но вот что со знаковыми числами?

Стандарт говорит, что переполнение знаковых целых чисел - undefined behaviour. Но почему?

Ну как минимум потому что стандарт отдавал на откуп компиляторам выбор представления отрицательных чисел. Как ранее мы обсуждали, выбирать приходится между тремя представлениями: обратный код, дополнительный код и метод "знак-амплитуда".

Так вот во всех трех сценариях результат переполнения будет разный!

Возьмем для примера дополнительный код и 4-х байтное знаковое число. Ноль выглядит, как 000...00, один как 000...01 и тд. Максимальное значение этого типа INT_MAX выглядит так: 0111...11 (2,147,483,647). Но! Когда мы прибавляем к нему единичку, то получаем 100...000, что переворачиваем знаковый бит, число становится отрицательным и равным INT_MIN.

Однако для обратного кода те же рассуждения приводят к тому, что результатом вычислений будет отрицательный ноль!

Ситуация здесь на самом деле немного поменялась с приходом С++20, который сказал нам, что у нас теперь единственный стандартный способ представления отрицательных чисел - дополнительный код. Об этих изменениях расскажу в следующем посте.

Don't let the patience cup overflow. Stay cool.

#cpp20 #compiler #cppcore
​​Проверяем на целочисленное переполнение

По просьбам трудящихся рассказываю, как определить, произошло переполнение или нет.

Почти очевидно, что если переполнение - это неопределенное поведение, то мы не хотим, чтобы оно возникало. Ну или хотя бы хотим, чтобы нам сигнализировали о таком событии и мы что-нибудь с ним сделали.

Какие вообще бывают переполнения по типу операции? Если мы складываем 2 числа, то их результат может не влезать в нужное количество разрядов. Вычитание тоже может привести к переполнению, если оба числа будут сильно негативные(не будьте, как эти числа). Умножение тоже, очевидно, может привести к overflow. А вот деление не может. Целые числа у нас не могут быть по модулю меньше единицы, поэтому деление всегда неувеличивает модуль делимого. Значит и переполнится оно не может.

И какая радость, что популярные компиляторы GCC и Clang уже за нас сделали готовые функции, которые могут проверять на signed integer overflow!

bool __builtin_add_overflow(type1 a, type2 b, type3 *res);
bool __builtin_sub_overflow(type1 a, type2 b, type3 *res);
bool __builtin_mul_overflow(type1 a, type2 b, type3 *res);


Они возвращают false, если операция проведена штатно, и true, если было переполнение. Типы type1, type2 и type3 должны быть интегральными типами.

Пользоваться функциями очень просто. Допустим мы решаем стандартную задачку по перевороту инта. То есть из 123 нужно получить 321, из 7493 - 3947, и тд. Задачка плевая, но есть загвоздка. Не любое число можно так перевернуть. Модуль максимального инта ограничивается двумя миллиадрами с копейками. Если у входного значения будут заняты все разряды и на конце будет 9, то перевернутое число уже не влезет в инт. Такие события хотелось бы детектировать и возвращать в этом случае фигу.

std::optional<int32_t> decimal_reverse(int32_t value) {
int32_t result{};
while (value) {
if (__builtin_mul_overflow(result, 10, &result) or
__builtin_add_overflow(result, value % 10, &result))
return std::nullopt;
value /= 10;
}
return result;
}

int main() {
if (decimal_reverse(1234567891).has_value()) {
std::cout << decimal_reverse(1234567891).value() << std::endl;
} else {
std::cout << "Reversing cannot be perform due overflow" << std::endl;
}

if (decimal_reverse(1234567894).has_value()) {
std::cout << decimal_reverse(1234567894).value() << std::endl;
} else {
std::cout << "Reversing cannot be perform due overflow" << std::endl;
}
}

// OUTPUT:
// 1987654321
// Reversing cannot be perform due overflow


Use ready-made solutions. Stay cool.

#cppcore #compiler
Как компилятор определяет переполнение

В прошлом посте я рассказал, как можно детектировать signed integer overflow с помощью готовых функций. Сегодня рассмотрим, что ж за магия такая используется для таких заклинаний.

Сразу с места в карьер. То есть в ассемблер.

Есть функция

int add(int lhs, int rhs) {
int sum;
if (__builtin_add_overflow(lhs, rhs, &sum))
abort();
return sum;
}


Посмотрим, во что эта штука компилируется под гцц х86.

Все немного упрощаю, но в целом картина такая:

    mov %edi, %eax
add %esi, %eax
jo call_abort
ret
call_abort:
call abort

Подготавливаем регистры, делаем сложение. А далее идет инструкция jo. Это условный прыжок. Если условие истино - прыгаем на метку call_abort, если нет - то выходим из функции.

Инструкция jo выполняет прыжок, если выставлен флаг OF в регистре EFLAGS. То есть Overflow Flag. Он выставляется в двух случаях:

1️⃣ Если операция между двумя положительными числами дает отрицательное число.

2️⃣ Если сумма двух отрицательных чисел дает в результате положительное число.

Можно считать, что это два условия для переполнения знаковых чисел. Например

127 + 127 = 0111 1111 + 0111 1111 = 1111 1110 = -2 (в дополнительном коде)

Результат сложения двух положительных чисел - отрицательное число, поэтому при таком сложении выставится регист OF.

Для беззнаковых чисел тоже кстати есть похожий флаг. CF или Carry Flag. Мы говорили, что переполнение для беззнаковых - не совсем переполнение, но процессор нам и о таком событии дает знать через выставление carry флага.

Собственно, вы и сами можете детектировать переполнение подобным образом. Конечно, придется делать асемблерную вставку, но тем не менее.

Но учитывая все условия для overflow, есть более простые способы его задетектить, чисто на арифметике. Но об этом позже.

Detect problems. Stay cool.

#base #cppcore #compiler
​​Signed Integer overflow

Переполнение знаковых целых чисел - всегда было и остается болью в левой булке. Раньше даже стандартом не было определено, каким образом отрицательные числа хранились бы в памяти. Однако с приходом С++20 мы можем смело утверждать, что стандартом разрешено единственное представление отрицательных чисел - дополнительный код или two's complement по-жидоанглосаксонски. Казалось бы, мы теперь знаем наверняка, что будет происходить с битиками при любых видах операций. Так давайте снимем клеймо позора с переполнения знаковых интов. Однако не все так просто оказывается.

С приходом С++20 только переполнение знаковых чисел вследствие преобразования стало определенным по стандарту поведением. Теперь говорится, что, если результирующий тип преобразование - знаковый, то значение переменной никак не изменяется, если исходное число может быть представлено в результирующем типе без потерь.
В обратном случае, если исходное число не может быть представлено в результирующем типе, то результирующим значением будет являться остаток от деления исходного значения по модулю 2^N, где N - количество бит, которое занимает результирующий тип. То есть результат будет получаться просто откидыванием лишних наиболее значащих бит и все!

Однако переполнение знаковых интов вследствие арифметических операций до сих пор является неопределенным поведением!(возмутительно восклицаю). Однако сколько бы возмущений не было, все упирается в конкретные причины. Я подумал вот о каких:

👉🏿 Переносимость. Разные системы работают по разным принципам и UB помогает поддерживать все системы оптимальным образом. Мы могли бы сказать, что пусть переполнение знаковых интов работает также как и переполнение беззнаковых. То есть получалось бы просто совершенно другое неожиданное (ожидаемое с точки зрения стандарта, но неожиданное для нас при запуске программы) значение. Однако некоторые системы просто напросто не продуцируют это "неправильное значение". Например, процессоры MIPS генерируют CPU exception при знаковом переполнении. Для обработки этих исключений и получения стандартного поведения было бы потрачено слишком много ресурсов.

👉🏿 Оптимизации. Неопределенное поведение позволяет компиляторам предположить, что переполнения не произойдет, и оптимизировать код. Действительно, если УБ - так плохо и об этом знают все, то можно предположить, что никто этого не допустит. Тогда компилятор может заняться своим любимым делом - оптимизировать все на свете.
Очень простой пример: когда происходит сравнение a - 10 < b -10, то компилятор может просто убрать вычитание и тогда переполнения не будет и все пойдет, как ожидается.

Так что УБ оставляет некий коридор свободы, благодаря которому могут существовать разные сценарии обработки переполнения: от полного его игнора до включения процессором "сирены", что произошло что-то очень плохое.

Leave room for uncertainty in life. Stay cool.

#cpp20 #compiler #cppcore
​​Как передать в поток ссылку на объект?

Глупый вопрос на первый взгляд. Ну вот есть у вас функция

void RunSomeThread(const & SomeType obj) {...}


И у вас есть какой-то супернеобычный объект и вы хотите запустить тред именно с этим объектом. Да даже большая строка подойдет. Не хотите вы копировать эту большую строку. Ну благо, ваша функция принимает константную ссылку, так что все отлично, никакого копирования.

std::thread thr(RunSomeThread, obj);


Запускаете прогу, все нормально работает, вы довольный пьете кофеек. Но решаете проверить логи. Так, на всякий случай. А вы очень не хотите лишних копирований объектов SomeFunckingType. Поэтому логируете создание этих объектов. И в логах обнаруживаете странную штуку: ваш объект скопировался. WTF???

Дело в том, что новосозданный поток копирует аргументы своего конструктора в свой внутренний сторадж. Зачем это нужно? Проблема в том, что параметры, которые вы передали, могут не пережить время жизни потока и удалиться до его завершения. Тогда обращение к ним по ссылке вело бы к неопределенному поведению. Но копирование выполняется только для тех параметров, которые переданы по значению. Если передавать параметр по ссылке, то ссылка передастся во внутренний сторадж потока без копирования. Это нужно делать только тогда, когда вы на 100% уверены, что ваш аргумент переживет цикл жизни потока.

"Но я же передал obj по ссылке!" Погоди....

Поскольку в плюсах так просто передать в функцию объект по ссылке - нужно просто указать имя этого объекта в параметрах функции, то конструктор std::thread сознательно предотвращает такие неосознанные манипуляции. Тут нужно явно показать, что мы знаем о проблеме и готовы идти на риск.

А сделать это очень просто. С помощью std::ref. Эта функция оборачивает ваш объект в другой шаблонный класс std::reference_wrapper, который хранит адрес вашего объекта. Теперь вы можете написать вот так:

std::thread thr(RunFuckingThread, std::ref(obj));


И никакие копирования вам не страшны! Копируется как бы этот объект, но он хранит указатель на ваш оригинальный объект, поэтому вы и имеете доступ непосредственно к нему.

Помните, что вы должны максимально осознанно пользоваться этим инструментом. Иначе нарветесь на какую-нибудь неприятную муть.

Stay conscious. Stay cool.

#concurrency #cppcore #memory
​​Неименованные параметры функций

С++ позволяет не указывать имена параметров функций, если они не используются в коде.


void foo(int /no name here/);

void foo(int /no name here/)
{
std::cout << "foo" << std::endl;
}

foo(5);


Это можно делать и в объявлении функции, и в ее определении.

Важный момент, что отсутствие имени параметра не говорит о том, что параметра нет и его не нужно передавать. Для вызова такой функции вы должны передать в нее аргумент соответствующего типа. Даже если он ничего не делает полезного.

Но вот вопрос возникает тогда. Если параметр ничего не делает, нахрена он тогда вообще нужен?

На самом деле много кейсов, где неименованный параметр может пригодится.

💥 Допустим, у вас есть функция, которая используется в очень многих местах кода, может даже через какие-нибудь указатели на функцию. И в один момент времени часть функционала стала ненужной и один или несколько параметров стали ненужны. Править все вызовы этой функции было бы болью, особенно если туда вовлечены function поинтеры. Вместо этого вы можете сделать эти параметры безымянными, чтобы явно в коде показать, что этот параметр не используется. Его и нельзя даже будет использовать.

💥 Заглушки. Зачастую для тестирования функциональности применяют сущности-болванки, которые внешне ведут себя, как нормальные ребята, но на самом деле они лодыри и ничего путного не делают. Это нужно для мокания соседних модулей, чтобы протестировать только функциональность выбранного набора модулей. Такие заглушки должны выглядеть подобающе, то есть полностью повторять апи замоканой сущности, но могут не делать никакой полезной работы. Поэтому можно в этом апи сделать безымянные параметры, чтобы еще раз подчеркнуть, что они не используются.

💥 Иногда существующие сущности в коде требуют коллбэки определенного вида. И вам в своем коллбэке возможно не нужно использовать весь набор параметров. Но для соблюдения апи вы должны их указать в сигнатуре своего обратного вызова. В этом случае можно сделать эти параметры безымянными.

💥 Иногда в иерархии полиморфных классов в конкретном наследнике вам не нужны все параметры виртуальной функции. Но для поддержания корректности переопределения виртуального интерфейса вы должны включить все параметры в сигнатуру метода. Опять же, неиспользуемые параметры можно пометить безымянными.

💥 Знаменитая перегрузка постфиксного оператора инкремента/декремента. Есть 2 вида этих операторов: префикстный и постфиксный. Проблема в том, что это все еще вызов функции operator++. Как различить реализации этих функций? Правильно, нужна перегрузка. Вот здесь и приходит на помощь безымянный параметр: в коде он не нужен, но влияет на выбор конкретной перегрузки. Выглядит это так:

struct Digit
{
Digit(int digit=0) : m_digit{digit} {}
Digit& operator++(); // prefix has no parameter
Digit operator++(int); // postfix has an int parameter
private:
int m_digit{};
};


В целом, эта фича нужна либо для соблюдения существующего апи, либо для того, чтобы при вызове функции гарантировано вызвалась правильная перегрузка.

Stay useful. Stay cool.

#cppcore #design
​​Достигаем недостижимое

В прошлом посте вот такой код:

int main() {
while(1);
return 0;
}

void unreachable() {
std::cout << "Hello, World!" << std::endl;
}


Приводил к очень неожиданным сайд-эффектам. При его компиляции клангом выводился принт, хотя в функции main мы нигде не вызываем unreachable.

Темная магия это или проделки ГосДепа узнаем дальше.

Для начала, этот код содержит UB. Согласно стандарту программа должна производить какие-то обозримые эффекты. Или завершиться, или работать с вводом-выводом, или работать с volatile переменными, или выполнять синхронизирующие операции. Это требования forward progress. Если программа ничего из этого не делает - код содержит UB.

Так вот у нас пустой бесконечный цикл. То есть предполагается, что он будет работать бесконечно и ничего полезного не делать.

Тут очень важно понять одну вещь. Компилятор следует не вашей логике и ожиданиям, как должна работать программа. У него есть фактически инструкция(стандарт), которой он следует.

По стандарту программа, содержащая бесконечные циклы без side-эффектов, содержит UB и компилятор имеет право делать с этим циклом все, что ему захочется.

В данном случае он просто удаляет цикл. Но он не только удаляет цикл. Но еще и удаляет инструкцию возврата из main.

В нормальных программах функция main в ассемблере представляет из себя следующее:

main:
// Perform some code
ret


ret - инструкция возврата из функции. И код функции main выполняется, пока не достигнет этой инструкции.

Так вот в нашем случае этой инструкции нет и код продолжает выполнение дальше. А дальше у нас очень удобненько расположилась функция с принтом, вывод которой мы и видим. Выглядит это так:

main:

unreachable():
push rax
mov rdi, qword ptr [rip + std::cout@GOTPCREL]
lea rsi, [rip + .L.str]
call std::basic_ostream<char, std::char_traits<char>>...


Почему удаляется return - не так уж очевидно и для самих разработчиков компилятора. У них есть тред обсуждения этого вопроса, который не привел к какому-то знаменателю. Так что не буду городить догадок.

Справедливости ради стоит сказать, что в 19-м шланге поменяли это поведение и теперь таких неожиданностей нет.

Stay predictable. Stay cool.

#fun #cppcore #compiler
​​Достаем элемент из последовательного контейнера

Обработка ошибок, да и в принципе нежелательных путей развития ситуации, может порождать много споров, боли, баттхерта и, возможно, вызывать глобальное потепление. А ее отсутствие может порождать много багов. И вот один из интересных кейсов, на который все не обращают внимание и, при этом, все пользуются этой функциональностью.

Я говорю о попе элементов. Не вот этой ( | ), а вот этом

void pop_back();

void pop_front();


Эти методы достают из контейнера элементы из зада или из переда соответственно.

"Какие тут проблемы?" - спросите вы.

И я вам отвечу.

Что произойдет, если я вызову эти методы на пустом контейнере? Если вы задумались, то это нормально, обычно такого не происходит. Но вот я такой Маша-растеряша и забыл проверить на пустоту перед вызовом. Будет UB.

Даже не исключение, которое можно обработать. Просто УБ. И можно УБиться в поисках бага, которая появится в следствии пропуска одной проверки.

Понятно, что так или иначе придется городить огород вокруг подобных моментов, когда что-то может пойти не так. Проблема конкретно этого кейса, что из сигнатура метода настолько безобидная, что даже и мысли не возникает, что может что-то не так пойти. А внезапно может прилететь по башке лопатой.

Туда же идут и методы front() и back(). Они дают такое же UB, когда контейнер пуст.

Почему так сложилось? Вопрос сложный.

Но не в этом суть.

Суть в том, что не нужно делать похожий интерфейс у своих классов. Давайте пользователю сразу понять, что в функции может что-то пойти не так и эту ситуацию надо обработать.

Споры о дизайне - горячо любимый всеми процесс. Каждый может решать этот вопрос по-разному.

Даже что-то подобное, на мой взгляд, куда более безопасный дизайн:

bool pop_back() {
if (data_.empty()) {
return false;
}
// remove element
return true;
}


А если его еще и аттрибутом nodiscard пометить, будет вообще щикарно(привет фанатам южного парка).

Может это и не лучшее решение для стандартной библиотеки. Вполне представляю, что это все бред и комитет лучше знает.

Но язык С++ никогда не славился своей безопасностью. И если вы можете своими силами обезопасить свой проект - нужно это делать. Даже таким несовершенным образом.

Stay safe. Stay cool.

#cppcore #STL