А чё, так можно было? int == -int

от автора

Есть число, которое не меняется от смены знака. Минус перед ним ничего не делает, а модуля у него не существует: Math.Abs бросает исключение.

Это int.MinValue. Компилятор такую проверку не соберёт, но int.MinValue == unchecked(-int.MinValue) возвращает true. 

Машина

Процессор

Система

Комп 1

Intel Core i9-10900KF 3.70GHz, 10 ядер

Windows 10 22H2

Комп 2

AMD Ryzen 9 5950X 3.39GHz, 16 ядер

Windows 10 1809

Комп 3

Intel Xeon W-2255 3.70GHz, 10 ядер

Windows Server 2022

Комп 4

Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра

Windows Server 2022

Все машины x64

Рантаймы .NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8. Числа в тексте с AMD Ryzen 9 5950X, таблицы по всем четырём машинам.

1. Число, у которого нет модуля

Исходник

Отрицательных чисел в int на одно больше, чем положительных: от −2 147 483 648 до 2 147 483 647. У нижней границы нет положительной пары, поэтому смена знака её не меняет.

int min = int.MinValue; Console.WriteLine(min);              // -2147483648Console.WriteLine(-min);             // -2147483648Console.WriteLine(min == -min);      // True

Библиотечный модуль об этом знает. Вот как он написан:

public static int Abs(int value){    if (value < 0)    {        value = -value;        if (value < 0)        {            ThrowNegateTwosCompOverflow();        }    }    return value;}

Число меняет знак, и результат проверяется повторно. Если после смены знака результат остался отрицательным, модуля не существует и метод бросает исключение.

Что выводит отчёт

  тип           самое маленькое          -x равен x   модуль        x / -1   sbyte                          -128          да    исключение          -128  short                        -32768          да    исключение        -32768  int                     -2147483648          да    исключение    исключение  long           -9223372036854775808          да    исключение    исключение  nint           -9223372036854775808          да    исключение    исключение

Одинаково на четырёх машинах и трёх рантаймах.

Причина

Отрицательные числа хранятся в дополнительном коде. Старший бит означает знак, а смена знака — это инверсия всех битов плюс единица.

У нижней границы все биты, кроме старшего, нулевые. Инверсия ставит единицы во всех разрядах, прибавление единицы переносит разряды до конца, и получается исходное число.

Где легко ошибиться

Написать свой модуль и решить, что он лучше библиотечного:

int Abs(int value) => value < 0 ? -value : value;

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

На sbyte и short ошибиться ещё легче. Смена знака ведёт себя там точно так же, но заметить это труднее: перед вычислением оба типа расширяются до int, где промежуточный результат укладывается в диапазон.

2. Деление на минус единицу

Исходник

Обычное деление, ничего особенного:

int left = int.MinValue;int right = -1; int result = left / right;   // System.OverflowException

Что выводит отчёт

Последний столбец той же таблицы:

  тип           самое маленькое          -x равен x   модуль        x / -1   sbyte                          -128          да    исключение          -128  short                        -32768          да    исключение        -32768  int                     -2147483648          да    исключение    исключение  long           -9223372036854775808          да    исключение    исключение  nint           -9223372036854775808          да    исключение    исключение

У int, long и nint — исключение. У sbyte и short деление проходит, и результат ожидаемый.

Причина

Правильный ответ равен 2 147 483 648, а такого числа в int нет. Деление в этом смысле ничем не отличается от смены знака.

Проверку делает не библиотека, а процессор. Инструкция целочисленного деления на x86 сама сообщает об ошибке, когда частное не помещается в регистр. Рантайм превращает это в OverflowException.

Именно поэтому sbyte и short не падают: перед делением они расширяются до int, где 128 и 32 768 укладываются в диапазон свободно.

Где легко ошибиться

Считать, что деление не бросает исключений, раз делитель не нулевой. Проверка if (right != 0) от этого случая не спасает.

Остаток ведёт себя так же: int.MinValue % -1 тоже бросает исключение, хотя математически остаток тут равен нулю.

3. Два миллиарда плюс два миллиарда

Исходник

int big = 2_000_000_000;int sum = big + big; Console.WriteLine(sum);   // -294967296

Ни исключения, ни предупреждения. По умолчанию C# работает в непроверяемом контексте, поэтому старшие биты отбрасываются.

А то же самое на константах компилятор не пропустит:

const int big = 2_000_000_000;int sum = checked(big + big); // error CS0220: The operation overflows at compile time in checked mode

Значения констант известны при сборке, поэтому компилятор вычисляет результат заранее. Это делается с проверкой на переполнение, потому и падает ошибка.

С переменными такое невозможно: их значения появятся только при выполнении. Поэтому код собирается, а переполнение остаётся незамеченным.

Что показывает замер

Цикл складывает 10 000 чисел. Набор подобран так, чтобы сумма укладывалась в диапазон: иначе вариант с проверкой бросает исключение, и замер показывает не работу проверки, а работу обработчика ошибки.

Способ

Комп 1

Комп 2

Комп 3

Комп 4

без проверки

2,521

2,599

2,928

4,749

с проверкой

2,600

2,409

3,057

4,951

в длинном типе

2,488

2,407

2,946

4,762

Микросекунды, .NET 10 

Разница от 3 до 4 процентов, а на Ryzen 9 5950X версия с проверкой оказалась быстрее. То есть на таком коде проверка теряется в шуме.

Причина

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

Где легко ошибиться

Решить, что раз проверка ничего не стоит, её можно включить на весь проект и забыть. Ключ CheckForOverflowUnderflow в файле проекта включает её везде, и любое место, где переполнение задумано специально, начнёт падать. Хеш-функции, счётчики и работа с битами обычно рассчитывают на отбрасывание старших разрядов.

4. Двоичный поиск с отрицательным индексом

Исходник

Привычная запись середины отрезка:

int mid = (low + high) / 2;

Пока границы небольшие, всё правильно. Когда сумма перестаёт помещаться в int, середина становится отрицательной.

Что выводит отчёт

  low            high          привычно       разность     беззнаково              0             10              5             5              5             0     2000000000     1000000000    1000000000     1000000000    1000000000     2000000000     -647483648    1500000000     1500000000    1500000000     2000000000     -397483648    1750000000     1750000000    2000000000     2147483647      -73741824    2073741823     2073741823    2147483646     2147483647             -1    2147483646     2147483646 Поиск числа 2000000000 на отрезке от 0 до 2147483647:   привычно      IndexOutOfRangeException: номер -536870912  разность      найдено, шагов: 31  беззнаково    найдено, шагов: 31

Причина

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

int i = lo + ((hi - lo) >> 1);

А в SpanHelpers — беззнаковое сложение, и рядом комментарий, зачем так:

// PERF: `lo` or `hi` will never be negative inside the loop,//       so computing median using uints is safe since we know//       `length <= int.MaxValue`, and indices are >= 0//       and thus cannot overflow an uint.//       Saves one subtraction per loop compared to//       `int i = lo + ((hi - lo) >> 1);`int i = (int)(((uint)hi + (uint)lo) >> 1);

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

Что показывает замер

Способ

Комп 1

Комп 2

Комп 3

Комп 4

привычная запись

5,165

5,891

6,122

7,964

через разность

5,217

5,280

6,070

7,645

беззнаково

5,167

5,288

6,049

7,627

Наносекунды на четыре отрезка, .NET 10

Обещанной экономии в замере не видно: разница между вторым и третьим способом от 0,3 до 0,4 процента, а на Intel Core i9-10900KF её нет. Инструкция действительно исчезает, но на времени это не сказывается.

Где легко ошибиться

Решить, что случай надуманный, раз массивов на два миллиарда элементов не бывает. Границы берутся не только из длины массива: это могут быть идентификаторы записей, отметки времени или номера страниц, а там такие значения встречаются постоянно.

Границы замеров

Все замеры сняты на x64 под Windows, на .NET 8, 9 и 10.

Числа для замера берутся из массива, а не пишутся в коде: константные выражения компилятор вычисляет при сборке, и замерять было бы нечего. У всех измеряемых методов запрещено встраивание.

Двоичный поиск в отчёте идёт по массиву, которого нет: значение элемента равно его номеру, поэтому обращение к памяти заменено вычислением. Проверяется только арифметика границ.

В 32-битной сборке nint займёт четыре байта, и его строка в отчёте будет другой.

Код из статьи

  • OverflowProof — замеры, отчёты и выгрузки с четырёх машин

Ссылки

Всем удачи и до новых встреч!

ссылка на оригинал статьи https://habr.com/ru/articles/1080958/