Является ли использование unsigned, а не подписанного int более вероятным причиной ошибок? Зачем?
В Руководстве по стилю Google C++ по теме "Беззнаковые целые числа" предлагается, чтобы
Из-за исторической аварии стандарт C++ также использует целые числа без знака для представления размера контейнеров - многие члены органа стандартов считают, что это ошибка, но на данный момент его невозможно зафиксировать. Тот факт, что беззнаковая арифметика не моделирует поведение простого целого числа, но вместо этого определяется стандартом для моделирования модульной арифметики (обертывание при переполнении/потоке), означает, что компилятор не может диагностировать значительный класс ошибок.
Что не так с модульной арифметикой? Разве это не ожидаемое поведение unsigned int?
Какие ошибки (значимый класс) относится к руководству? Переполненные ошибки?
Не используйте тип unsigned, чтобы утверждать, что переменная неотрицательна.
Одна из причин, по которой я могу думать об использовании подписанного int over unsigned int, заключается в том, что если он переполняется (отрицательным), его легче обнаружить.
Ответы
Ответ 1
В некоторых ответах здесь упоминаются удивительные правила продвижения между значениями, подписанными и неподписанными, но это скорее похоже на проблему, связанную с смешиванием подписанных и неподписанных значений, и не обязательно объясняет, почему подписка предпочтительнее без знака, вне сценариев смешивания.
По моему опыту, вне смешанных сравнений и правил продвижения, есть две основные причины, по которым неподписанные значения являются большими магнитами-ошибками.
Неподписанные значения имеют разрыв в нуле, наиболее распространенное значение в программировании
Оба беззнаковых и подписанных целых числа имеют разрывы при их минимальном и максимальном значениях, где они обертываются (без знака) или вызывают неопределенное поведение (подпись). Для 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 number
1, и вы сделаете 4 миллиарда итераций и почти имеете уязвимость переполнения буфера.
Вам нужно написать так:
for (size_t i = 0; i + 1 < v.size(); i++) { // do something }
Поэтому в этом случае он может быть "исправлен", но только тщательно продумывая беззнаковый характер size_t
. Иногда вы не можете применить исправление выше, потому что вместо постоянного вы имеете какое-либо смещение переменной, которое вы хотите применить, что может быть положительным или отрицательным: так, какая "сторона" сравнения, которую вы должны надеть, зависит от подписанности - теперь код становится очень грязным.
Аналогичная проблема возникает с кодом, который пытается выполнить итерацию до нулевого и включая нуль. Что-то вроде while (index-- > 0)
отлично работает, но, по-видимому, эквивалентный while (--index >= 0)
никогда не завершится для значения без знака. Ваш компилятор может предупредить вас, когда правая сторона является буквальной ноль, но, конечно, нет, если это значение, определенное во время выполнения.
контрапункт
Некоторые могут утверждать, что подписанные значения также имеют два разрыва, поэтому зачем выбирать без знака? Разница в том, что оба разрыва очень (максимально) далеки от нуля. Я действительно считаю, что это отдельная проблема "переполнения", как подписанные, так и неподписанные значения могут переполняться с очень большими значениями. Во многих случаях переполнение невозможно из-за ограничений на возможный диапазон значений, а переполнение многих 64-битных значений может быть физически невозможным). Даже если это возможно, вероятность ошибки, связанной с переполнением, часто бывает незначительной по сравнению с ошибкой "на нуле", а переполнение происходит и для неподписанных значений. Таким образом, unsigned объединяет худшее из обоих миров: потенциальное переполнение с очень большими значениями величины и разрыв в ноль. Подписано только первое.
Многие будут утверждать, что "вы потеряете немного" с unsigned. Это часто верно, но не всегда (если вам нужно представлять различия между неподписанными значениями, вы все равно потеряете этот бит: так много 32-битных вещей ограничены 2 гигабайтами в любом случае, или у вас будет странная серая область, где говорят файл может быть 4 GiB, но вы не можете использовать определенные API на второй половине 2 гигабайта).
Даже в тех случаях, когда неподписанные покупают вас немного: он не покупает вас много: если вам нужно было поддерживать более 2 миллиардов "вещей", вам, вероятно, скоро придется поддерживать более 4 миллиардов.
Логически, неподписанные значения представляют собой подмножество значащих значений
Математически значения без знака (неотрицательные целые числа -N) представляют собой подмножество целых чисел со знаком (просто называемых _integers). 2. Но подписанные значения, естественно, выходят из операций исключительно на неподписанные значения, такие как вычитание. Можно сказать, что неподписанные значения не закрываются при вычитании. То же самое относится и к подписанным значениям.
Хотите найти "дельту" между двумя беззнаковыми индексами в файл? Ну, вам лучше сделать вычитание в правильном порядке, иначе вы получите неправильный ответ. Конечно, вам часто требуется проверка времени выполнения, чтобы определить правильный порядок! При работе с неподписанными значениями в качестве чисел вы часто обнаружите, что (логически) подписанные значения продолжают появляться в любом случае, поэтому вы можете начать с подписания.
контрапункт
Как уже упоминалось в сноске (2) выше, подписанные значения в C++ на самом деле не являются подмножеством беззнаковых значений одного и того же размера, поэтому значения без знака могут представлять одинаковое количество результатов, которые могут иметь значения со знаком.
Правда, но диапазон менее полезен. Рассмотрим вычитание и беззнаковые числа с диапазоном от 0 до 2N и подписанные числа с диапазоном -N до N. Произвольные вычитания приводят к результатам в диапазоне -2 N до 2N в _both случаях, и любой тип целых чисел может представлять только половину. Хорошо, оказывается, что область, центрированная вокруг нуля -N до N, обычно более полезна (содержит более реальные результаты в реальном мире), чем диапазон от 0 до 2N. Рассмотрим любое типичное распределение, отличное от однородного (log, zipfian, normal, whatever) и рассмотрим вычитание случайно выбранных значений из этого распределения: путь больше значений в [-N, N], чем [0, 2N] (действительно, в результате распределение всегда сосредоточено на нуле).
64-бит закрывает дверь по многим причинам, чтобы использовать подписанные значения как числа
Я думаю, что приведенные выше аргументы уже требовали 32-битных значений, но случаи переполнения, которые затрагивают как подписанные, так и неподписанные в разных пороговых значениях, имеют место для 32-битных значений, поскольку "2 миллиарда" - это число, которое может превышать многие абстрактные и физические величины (миллиарды долларов, миллиарды наносекунд, массивы с миллиардами элементов). Поэтому, если кто-то достаточно убедится в удвоении положительного диапазона для неподписанных значений, они могут сделать так, что переполнение имеет значение, и оно слегка способствует отсутствию знака.
Вне специализированных областей 64-битные значения в значительной степени устраняют эту проблему. Подписанные 64-битные значения имеют верхний диапазон 9,223,372,036,854,775,807 - более девяти квинтиллионов. Это много наносекунд (около 292 лет), и много денег. Это также больший массив, чем любой компьютер, вероятно, будет иметь ОЗУ в когерентном адресном пространстве в течение длительного времени. Так что, может быть, 9 квинтильонов хватит для всех (пока)?
Когда использовать значения без знака
Обратите внимание, что руководство по стилю не запрещает или даже обязательно препятствует использованию неподписанных номеров. На этом заканчивается:
Не используйте тип unsigned, чтобы утверждать, что переменная не является -N.
Действительно, есть хорошие применения для неподписанных переменных:
-
Если вы хотите рассматривать количество N-бит не как целое, а просто "мешок с битами". Например, как битовая маска или растровое изображение, или N логических значений или что-то еще. Это использование часто идет рука об руку с типами фиксированной ширины, такими как uint32_t
и uint64_t
поскольку вы часто хотите знать точный размер переменной. Подсказка, что конкретная переменная заслуживает этого лечения, заключается в том, что вы работаете с ней только с помощью побитовых операторов, таких как ~
, |
, &
, ^
, >>
и т.д., а не с арифметическими операциями, такими как +
, -
, *
, /
и т.д.
Беззнаковое является идеальным здесь, потому что поведение побитовых операторов хорошо определено и стандартизировано. Подписанные значения имеют несколько проблем, таких как неопределенное и неуказанное поведение при переключении и неопределенное представление.
- Когда вы действительно хотите модульную арифметику. Иногда вам действительно нужна модульная арифметика 2 ^ N. В этих случаях "переполнение" - это функция, а не ошибка. Беззнаковые значения дают вам то, что вы хотите здесь, поскольку они определены для использования модульной арифметики. Подписанные значения не могут быть (легко, эффективно) использованы вообще, поскольку они имеют неопределенное представление и переполнение не определено.
0.5 После того, как я написал это, я понял, что это почти идентично примеру Джарода, которого я не видел - и, честно, это хороший пример!
1 Мы говорим о size_t
здесь, так обычно 2 ^ 32-1 в 32-битной системе или 2 ^ 64-1 на 64-битной.
2 В C++ это не совсем так, потому что неподписанные значения содержат больше значений в верхнем конце, чем соответствующий подписанный тип, но существует основная проблема, заключающаяся в том, что манипулирование неподписанными значениями может привести к (логически) подписанным значениям, но нет соответствующая проблема с подписанными значениями (поскольку уже подписанные значения уже включают значения без знака).
Ответ 2
Как было сказано, смешивание 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
, то s.size() - 5
было бы отрицательным, и в приведенном выше случае условие было бы ложным немедленно.
С другой стороны, индекс должен быть между [0; v.size()[
[0; v.size()[
так unsigned
имеет смысл. Подписанный имеет также свою собственную проблему как UB с переполнением или определенным поведением для изменения сдвига отрицательного числа, но менее частым источником ошибок для итерации.
Ответ 3
Одним из наиболее привлекательных примеров ошибки является то, что вы подписали MIX и unsigned значения:
#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 миллиардам, поэтому с неподписанными типами многие ваши значения ближе к краю допустимого диапазона. Чтобы ухудшить ситуацию, конечное значение может быть в известном положительном диапазоне, но при оценке выражений промежуточные значения могут быть истощены и если они используются в промежуточной форме, могут быть ОЧЕНЬ неправильными значениями. Наконец, даже если ваши значения всегда будут положительными, это не означает, что они не будут взаимодействовать с другими переменными, которые могут быть отрицательными, и поэтому вы оказываетесь в принудительном состоянии смешения подписных и неподписанных типов, что худшее место.
Ответ 4
Почему использование unsigned int чаще приводит к ошибкам, чем использование подписанного int?
Использование неподписанного типа, скорее всего, не приведет к ошибкам, чем использование подписанного типа с определенными классами задач.
Используйте правильный инструмент для работы.
Что не так с модульной арифметикой? Разве это не ожидаемое поведение unsigned int?
Почему использование unsigned int чаще приводит к ошибкам, чем использование подписанного int?
Если задача хорошо согласована: ничего плохого. Нет, не более вероятно.
Безопасность, шифрование и алгоритм аутентификации рассчитываются на неподписанную модульную математику.
Алгоритмы сжатия/декомпрессии также, как и различные графические форматы, выигрывают и менее искажены с неподписанной математикой.
Каждый раз, когда используются битовые операторы и сдвиги, неподписанные операции не перепутаются с проблемами расшифровки подписей со знаком математики.
Подписанная целочисленная математика имеет интуитивно понятный внешний вид, который легко понять всеми учащимися в кодировании. C/C++ первоначально не был нацелен, и теперь он не должен быть интроязыком. Для быстрого кодирования, в котором используются системы безопасности, связанные с переполнением, лучше подходят другие языки. Для постного быстрого кода C предполагает, что кодеры знают, что они делают (они испытаны).
Ловушкой подписанной математики сегодня является вездесущий 32-битный int
который с таким количеством проблем достаточно широк для обычных задач без проверки диапазона. Это приводит к самоуспокоенности, что переполнение не кодируется. Вместо этого for (int i=0; я < n; i++)
int len = strlen(s);
рассматривается как ОК, поскольку 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
over unsigned
. Однако int
math не очень хорошо защищена.
Узкие 16-битные функции int/unsigned
применяются сегодня с выбором встроенных приложений.
Рекомендации Google хорошо применяются для кода, который они пишут сегодня. Это не является окончательным руководством для более широкого широкого диапазона кода C/C++.
Одна из причин, по которой я могу думать об использовании подписанного int over unsigned int, заключается в том, что если он переполняется (отрицательным), его легче обнаружить.
В C/C++ подписанное переполнение int math является неопределенным поведением и поэтому не обязательно легче обнаружить, чем определено поведение неподписанной математики.
Как хорошо комментирует @Chris Uzdavinis, смешивание подписанных и неподписанных лучше всего избегать всеми (особенно начинающими) и в противном случае тщательно кодировать, когда это необходимо.
Ответ 5
У меня есть некоторый опыт в руководстве по стилю Google, AKA The Hitchhiker Guide to Insane Directives от Bad Programmers, которые долгое время попадали в компанию. Этот конкретный ориентир - всего лишь один пример из десятков правил орехов в этой книге.
Ошибки возникают только с неподписанными типами, если вы пытаетесь сделать арифметику с ними (см. Пример Криса Уздавиниса выше), другими словами, если вы используете их как числа. Неподписанные типы не предназначены для хранения числовых величин, они предназначены для хранения счетчиков, таких как размер контейнеров, которые никогда не могут быть отрицательными, и они могут и должны использоваться для этой цели.
Идея использования арифметических типов (например, целых чисел) для хранения размеров контейнеров является идиотской. Не могли бы вы использовать двойной, чтобы сохранить размер списка? Что есть люди в Google, хранящие размеры контейнеров с использованием арифметических типов и требующие от других делать то же самое, что-то говорит о компании. Одна вещь, которую я замечаю в отношении таких диктов, заключается в том, что чем глупее они являются, тем больше они должны быть строгими правилами, которые вы делаете, или вы уволены, потому что иначе люди с здравым смыслом игнорируют правило.
Ответ 6
Использование неподписанных типов для представления неотрицательных значений...
- скорее всего, вызовет ошибки, связанные с поощрением типов, при использовании значений с подписью и без знака, поскольку другой ответ демонстрирует и обсуждает в деталях, но
- менее вероятно, вызовет ошибки, связанные с выбором типов с доменами, способными представлять недопустимые/запрещенные значения. В некоторых местах вы предполагаете, что это значение находится в домене, и может стать неожиданным и потенциально опасным поведением, когда какое-то другое значение прокралось.
В Руководящих принципах кодирования Google основное внимание уделяется первому виду рассмотрения. Другие наборы руководств, такие как Основные принципы C++, ставят больший акцент на второй момент. Например, рассмотрите Основной принцип I.12:
I.12: Объявить указатель, который не должен иметь значение null как 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
оболочкой, что позволяет избежать обеих категорий ошибок, но у этого будут свои проблемы...