it-roy-ru.com

Является ли использование неподписанного, а не подписанного int более вероятным причиной ошибок? Зачем?

В Руководство по стилю Google C++ по теме "Целые числа без знака" предлагается

Из-за исторической случайности стандарт C++ также использует целые числа без знака для представления размера контейнеров - многие члены органа по стандартизации считают, что это ошибка, но на данный момент исправить это практически невозможно. Тот факт, что арифметика без знака не моделирует поведение простого целого числа, а вместо этого определяется стандартом для моделирования модульной арифметики (обтекание при переполнении/недостаточном заполнении), означает, что компилятор не может диагностировать значительный класс ошибок.

Что не так с модульной арифметикой? Разве это не ожидаемое поведение int без знака?

К каким ошибкам (значительный класс) относится руководство? Переполненные ошибки?

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

Одна из причин, по которой я могу подумать об использовании подписанного int вместо unsigned int, заключается в том, что, если он переполняется (до отрицательного значения), его легче обнаружить.

76
user7586189

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

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

Беззнаковые значения имеют разрыв в нуле, наиболее распространенное значение в программировании

И целые числа без знака и со знаком имеют разрывы в своих минимальных и максимальных значениях, где они оборачиваются (без знака) или вызывают неопределенное поведение (со знаком). Для unsigned эти точки равны нулю и UINT_MAX. Для int они находятся в INT_MIN и INT_MAX. Типичными значениями INT_MIN и INT_MAX в системе с 4-байтовыми значениями int являются -2^31 и 2^31-1, а в такой системе UINT_MAX обычно 2^32-1.

Основная проблема, вызывающая ошибку с unsigned, которая не относится к int, заключается в том, что она имеет разрыв в нуле . Ноль, конечно, является очень распространенным значением в программах, наряду с другими небольшими значениями, такими как 1,2,3. Обычно складывают и вычитают небольшие значения, особенно 1, в различных конструкциях, и если вы вычитаете что-либо из значения unsigned и оно оказывается равным нулю, вы просто получили огромное положительное значение и почти определенную ошибку.

Рассмотрим код, повторяющий все значения в векторе по индексу, кроме последнего0,5:

for (size_t i = 0; i < v.size() - 1; i++) { // do something }

Это работает нормально, пока однажды вы не передадите пустой вектор. Вместо того, чтобы делать ноль итераций, вы получаете v.size() - 1 == a giant number1 и вы сделаете 4 миллиарда итераций и почти будете иметь уязвимость переполнения буфера.

Вам нужно написать это так:

for (size_t i = 0; i + 1 < v.size(); i++) { // do something }

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

Существует аналогичная проблема с кодом, который пытается выполнить итерирование вплоть до нуля. Что-то вроде while (index-- > 0) работает нормально, но явно эквивалентный while (--index >= 0) никогда не завершится для значения без знака. Ваш компилятор может предупредить вас, когда правая часть равна буквальному нулю, но, конечно, нет, если это значение определяется во время выполнения.

Контрапункт

Некоторые могут возразить, что подписанные значения также имеют две несплошности, так зачем выбирать неподписанные? Разница в том, что оба разрыва очень (максимально) далеки от нуля. Я действительно считаю, что это отдельная проблема "переполнения", при этом значения со знаком и без знака могут переполняться при очень больших значениях. Во многих случаях переполнение невозможно из-за ограничений на возможный диапазон значений, а переполнение многих 64-битных значений может быть физически невозможно). Даже если это возможно, вероятность ошибки, связанной с переполнением, часто ничтожна по сравнению с ошибкой "в ноль", а переполнение происходит и для значений без знака . Таким образом, unsigned сочетает в себе худшее из обоих миров: потенциальное переполнение с очень большими значениями величины и разрыв в нуле. Подписано только бывшее.

Многие будут утверждать, что "вы немного потеряете" с неподписанным. Это часто верно, но не всегда (если вам нужно представить различия между значениями без знака, вы потеряете этот бит в любом случае: так много 32-битных вещей ограничено 2 GiB в любом случае, или вы будете иметь странная серая область, где, скажем, размер файла может составлять 4 ГиБ, но вы не можете использовать определенные API-интерфейсы во второй половине 2 GiB).

Даже в тех случаях, когда unsigned покупает вас немного: он мало что покупает: если вам нужно было поддерживать более 2 миллиардов "вещей", вам, вероятно, скоро придется поддерживать более 4 миллиардов.

Логически, неподписанные значения являются подмножеством подписанных значений.

Математически беззнаковые значения (неотрицательные целые числа) представляют собой подмножество целых чисел со знаком (просто называемых _integers).2, Тем не менее значения со знаком естественным образом выпадают из операций исключительно с значениями без знака , такими как вычитание. Можно сказать, что беззнаковые значения не закрыты при вычитании. То же самое не относится к подписанным значениям.

Хотите найти "дельту" между двумя беззнаковыми индексами в файле? Что ж, вам лучше сделать вычитание в правильном порядке, иначе вы получите неправильный ответ. Конечно, вам часто требуется проверка во время выполнения, чтобы определить правильный порядок! Имея дело со значениями без знака в виде чисел, вы часто обнаруживаете, что (логически) значения со знаком продолжают появляться в любом случае, так что вы могли бы также начать со знака.

Контрапункт

Как упоминалось в сноске (2) выше, подписанные значения в C++ на самом деле не являются подмножеством беззнаковых значений одинакового размера, поэтому беззнаковые значения могут представлять то же число результатов, что и подписанные значения.

Правда, но диапазон менее полезен. Рассмотрим вычитание, числа без знака с диапазоном от 0 до 2N и числа со знаком с диапазоном от -N до N. Произвольные вычитания приводят к результатам в диапазоне от -2N до 2N в обоих случаях, и любое целое число может представлять только половина этого. Хорошо получается, что область вокруг нуля от -N до N обычно более полезна (содержит больше реальных результатов в реальном коде), чем диапазон от 0 до 2N. Рассмотрим любое типичное распределение, отличное от равномерного (log, zipfian, normal и т.д.), И рассмотрим вычитание случайно выбранных значений из этого распределения: гораздо больше значений заканчивается в [-N, N], чем [0, 2N] (действительно, в результате получается распределение всегда в центре нуля).

64-разрядная версия закрывает двери по многим причинам использовать подписанные значения в качестве чисел

Я думаю, что приведенные выше аргументы уже были убедительными для 32-битных значений, но случаи переполнения, которые влияют как на знаковые, так и на беззнаковые при разных пороговых значениях, do происходят для 32-битных значений, так как "2 миллиарда" - это число, которое может превышать многие абстрактные и физические величины (миллиарды долларов, миллиарды наносекунд, массивы с миллиардами элементов). Таким образом, если кто-то достаточно убежден удвоением положительного диапазона для беззнаковых значений, он может доказать, что переполнение имеет значение, и оно слегка благоприятствует беззнаковому.

За пределами специализированных доменов 64-битные значения в значительной степени снимают эту проблему. 64-битные значения со знаком имеют верхний диапазон 9,223,372,036,854,775,807 - более девяти квинтиллионов . Это много наносекунд (около 292 лет) и много денег. Это также массив большего размера, чем у любого компьютера, который может долгое время иметь RAM в связанном адресном пространстве. Так что, может быть, 9 квинтиллионов хватит всем (пока)?

Когда использовать неподписанные значения

Обратите внимание, что руководство по стилю не запрещает или даже не поощряет использование чисел без знака. Он завершается с:

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

Действительно, есть хорошие применения для беззнаковых переменных:

  • Когда вы хотите обрабатывать N-разрядное количество не как целое число, а просто как "мешок с битами". Например, в качестве битовой маски или растрового изображения, или N логических значений или чего-либо еще. Такое использование часто идет рука об руку с типами фиксированной ширины, такими как uint32_t и uint64_t, так как вы часто хотите знать точный размер переменной. Намек на то, что определенная переменная заслуживает этой обработки, состоит в том, что вы работаете с ней только с помощью побитовых операторов, таких как ~, |, &, ^, >> и т.д., И не с такими арифметическими операциями, как +, -, *, / и т. д.

    Без знака здесь идеально, потому что поведение побитовых операторов четко определено и стандартизировано. У значений со знаком есть несколько проблем, таких как неопределенное и неопределенное поведение при сдвиге и неопределенное представление.

  • Когда вы на самом деле хотите модульную арифметику. Иногда вы действительно хотите 2 ^ N модульной арифметики. В этих случаях "переполнение" - это функция, а не ошибка. Значения без знака дают вам то, что вы хотите, поскольку они определены для использования модульной арифметики. Подписанные значения нельзя (легко, эффективно) использовать вообще, поскольку они имеют неопределенное представление, а переполнение не определено.

0,5 После того, как я написал это, я понял, что это почти идентично пример Джарода , которого я не видел - и по уважительной причине это хороший пример!

1 Мы говорим о size_t здесь, обычно 2 ^ 32-1 в 32-битной системе или 2 ^ 64-1 в 64-битной.

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

66
BeeOnRope

Как уже говорилось, смешивание unsigned и signed может привести к неожиданному поведению (даже если оно четко определено).

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

for (int i = 0; i < v.size() - 5; ++i) { foo(v[i]); } // Incorrect
// for (int i = 0; i + 5 < v.size(); ++i) { foo(v[i]); } // Correct

Предположим, что v.size() < 5, тогда как v.size() является unsigned, s.size() - 5 будет очень большим числом, и поэтому i < v.size() - 5 будет true для более ожидаемого диапазона значений i. И UB тогда происходит быстро (из привязанного доступа один раз i >= v.size())

Если бы v.size() вернул бы подписанное значение, то s.size() - 5 был бы отрицательным, и в указанном выше случае условие было бы немедленно ложным.

С другой стороны, индекс должен быть между [0; v.size()[, так что unsigned имеет смысл. У Signed также есть своя собственная проблема, как UB с переполнением или определяемым реализацией поведением для сдвига вправо отрицательного числа со знаком, но менее частым источником ошибок для итерации.

33
Jarod42

Один из наиболее распространенных примеров ошибки - это когда вы смешиваете значения со знаком и без знака:

#include <iostream>
int main()  {
    auto qualifier = -1 < 1u ? "makes" : "does not make";
    std::cout << "The world " << qualifier << " sense" << std::endl;
}

Результат:

Мир не имеет смысла

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

Моделирование типа неподписанного на основе ожидаемой области значений ваших чисел - плохая идея. Большинство чисел ближе к 0, чем к 2 миллиардам, поэтому с неподписанными типами многие ваши значения ближе к границе допустимого диапазона. Что еще хуже, значение окончательное может находиться в известном положительном диапазоне, но при оценке выражений промежуточные значения могут быть недооценены, и, если они используются в промежуточной форме, могут быть ОЧЕНЬ неправильные значения. Наконец, даже если ожидается, что ваши значения всегда будут положительными, это не значит, что они не будут взаимодействовать с другими переменными, которые могут быть отрицательными, и поэтому вы заканчиваете с вынужденной ситуацией смешивания типов со знаком и без знака, что является худшим местом.

19
Chris Uzdavinis

Почему использование неподписанного int чаще приводит к ошибкам, чем использование подписанного int?

Использование типа unsigned с большей вероятностью приведет к ошибкам, чем использование типа Signed с определенными классами задач.

Используйте правильный инструмент для работы.

Что не так с модульной арифметикой? Разве это не ожидаемое поведение int без знака?
Почему использование неподписанного int чаще вызывает ошибки, чем использование подписанного int?

Если задача хорошо согласована: ничего страшного. Нет, не более вероятно.

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

Алгоритмы сжатия/распаковки также, как и различные графические форматы, выигрывают и менее подвержены ошибкам с беззнаковой математикой.

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


Целочисленная математика со знаком имеет интуитивно понятный вид и легко воспринимается всеми, включая учеников, занимающихся программированием. C/C++ изначально не предназначался и не должен быть интроязыком. Для быстрого кодирования, в котором используются защитные сети от переполнения, лучше подходят другие языки. Для быстрого быстрого кода C предполагает, что кодеры знают, что они делают (они имеют опыт).

Подводная черта знаковой математики сегодня - это вездесущий 32-битный int, который с таким количеством проблем достаточно широк для обычных задач без проверки диапазона. Это приводит к самоуспокоенности, что переполнение не закодировано против. Вместо этого for (int i=0; i < n; i++)int len = strlen(s); рассматривается как OK, поскольку предполагается, что n <INT_MAX, и строки никогда не будут слишком длинными, вместо того, чтобы быть полностью защищенными в первом случае или использовать size_t, unsigned или даже long long во втором.

C/C++ был разработан в эпоху, которая включала как 16-битные, так и 32-битные int и дополнительный бит, который предоставляет 16-битный беззнаковый size_t, был значительным. Необходимо было обратить внимание на проблемы переполнения, будь то int или unsigned.

32-разрядные (или более широкие) приложения Google на не 16-разрядных платформах int/unsigned позволяют не обращать внимания на переполнение +/- int, учитывая его широкий диапазон. Для таких приложений имеет смысл поощрять int вместо unsigned. Тем не менее int математика не очень хорошо защищена.

Узкие 16-битные int/unsigned проблемы сегодня применяются с некоторыми встроенными приложениями.

Рекомендации Google хорошо подходят для кода, который они пишут сегодня. Это не окончательное руководство для более широкого диапазона кода C/C++.


Одна из причин, по которой я могу подумать об использовании подписанного int вместо unsigned int, заключается в том, что, если он переполняется (до отрицательного значения), его легче обнаружить.

В C/C++ математическое переполнение со знаком является неопределенным поведением и, следовательно, определенно не легче обнаружить, чем определенное поведение беззнаковой математики.


Поскольку @ Chris Uzdavinis хорошо прокомментировано, смешивание со знаком и без знака лучше всего избегать всеми (особенно начинающими) ) и в остальном тщательно кодируется при необходимости.

11
chux

У меня есть некоторый опыт работы с руководством по стилю от Google, AKA - Руководством автостопщика по безумным директивам от плохих программистов, которые давно в компании. Это конкретное руководство является лишь одним из десятков безумных правил в этой книге.

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

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

5
Tyler Durden

Использование беззнаковых типов для представления неотрицательных значений ...

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

Руководство по кодированию Google делает упор на первый вид рассмотрения. Другие наборы рекомендаций, такие как C++ Core Guidelines , уделяют больше внимания второму пункту. Например, рассмотрим Основное Руководство I.12 :

I.12. Объявить указатель, который не должен быть нулевым, как not_null

Причина

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

Пример

int length(const char* p);            // it is not clear whether length(nullptr) is valid
length(nullptr);                      // OK?
int length(not_null<const char*> p);  // better: we can assume that p cannot be nullptr
int length(const char* p);            // we must assume that p can be nullptr

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

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

1
einpoklum