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

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

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

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

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

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

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

6 ответов

Решение

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

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

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

И целые числа без знака и со знаком имеют разрывы в своих минимальных и максимальных значениях, где они оборачиваются (без знака) или вызывают неопределенное поведение (со знаком). За 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 сочетает в себе худшее из обоих миров: потенциальное переполнение с очень большими значениями величины и разрыв в нуле. Подписано только бывшее.

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

Даже в тех случаях, когда 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-разрядных значений, но случаи переполнения, которые влияют как на знаковые, так и на неподписанные значения при разных пороговых значениях, действительно имеют место для 32-разрядных значений, поскольку "2 миллиарда" - это число, которое может превышать многие абстрактные и физические величины (миллиарды долларов, миллиарды наносекунд, массивы с миллиардами элементов). Таким образом, если кто-то достаточно убежден удвоением положительного диапазона для беззнаковых значений, он может доказать, что переполнение имеет значение, и оно слегка благоприятствует беззнаковому.

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

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

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

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

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

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

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

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

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

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

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

Как указано, смешивание 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 с переполнением или определяемым реализацией поведением для сдвига вправо отрицательного числа со знаком, но менее частым источником ошибок для итерации.

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

#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 миллиардам, поэтому с беззнаковыми типами многие ваши значения ближе к границе допустимого диапазона. Что еще хуже, конечное значение может находиться в известном положительном диапазоне, но при оценке выражений промежуточные значения могут недооцениваться, и, если они используются в промежуточной форме, могут быть ОЧЕНЬ неправильные значения. Наконец, даже если ожидается, что ваши значения всегда будут положительными, это не означает, что они не будут взаимодействовать с другими переменными, которые могут быть отрицательными, и, таким образом, вы столкнетесь с вынужденной ситуацией смешивания типов со знаком и без знака, что худшее место, чтобы быть.

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

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

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

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

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

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

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

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


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

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

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++ переполнение int со знаком является неопределенным поведением, поэтому его не так просто обнаружить, как определенное поведение без знака по математике.


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

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

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

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

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

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

Руководство по кодированию Google делает упор на первый вид рассмотрения. Другие наборы руководств, такие как " Основные руководящие принципы C++", уделяют больше внимания второму пункту. Например, рассмотрим Базовое Руководство 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 Обертка для целых чисел, которая позволяет избежать обеих категорий ошибок, но у нее есть свои проблемы...

В заявлении Google говорится об использовании беззнакового типа в качестве типа размера для контейнеров. Напротив, вопрос кажется более общим. Пожалуйста, имейте это в виду, пока будете читать.

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

Подписанные размеры контейнера

Предположим, кто-то закодировал ошибку, которая приводит к отрицательному индексу контейнера. Результатом является либо неопределенное поведение, либо исключение / нарушение прав доступа. Это действительно лучше, чем получение неопределенного поведения или исключение / нарушение прав доступа, когда тип индекса был беззнаковым? Я думаю нет.

Теперь есть класс людей, которые любят говорить о математике и о том, что "естественно" в этом контексте. Как может целочисленный тип с отрицательным числом быть естественным для описания чего-то, что по своей сути>= 0? Много используете массивы с отрицательными размерами? IMHO, особенно математически склонные люди сочли бы это несоответствие семантики (размер / тип индекса говорит, что отрицательный возможен, в то время как массив отрицательного размера трудно представить) раздражающим.

Итак, единственный вопрос, который остается по этому поводу, заключается в том, может ли - как указано в комментарии Google - действительно ли компилятор активно помогать в обнаружении таких ошибок. И даже лучше, чем альтернатива, которая представляет собой целые числа без знака, защищенные от переполнения (сборка x86-64 и, возможно, другие архитектуры имеют средства для этого, только C/C++ не использует эти средства). Единственный способ понять, что компилятор автоматически добавил проверки времени выполнения (if (index < 0) throwOrWhatever) или в случае, если действия во время компиляции производят множество потенциально ложноположительных предупреждений / ошибок: "Индекс для доступа к этому массиву может быть отрицательным". У меня есть сомнения, это было бы полезно.

Кроме того, люди, которые на самом деле пишут проверки времени выполнения для своих индексов массива / контейнера, больше работают с целыми числами со знаком. Вместо того, чтобы писать if (index < container.size()) { ... } теперь вам нужно написать: if (index >= 0 && index < container.size()) { ... }. Мне это кажется принудительным трудом, а не улучшением...

Языки без беззнаковых типов - отстой...

Да, это удар по java. Я имею опыт программирования встраиваемых систем, и мы много работали с полевыми шинами, где двоичные операции (и, или, xor,...) и побитовая композиция значений - это буквально хлеб с маслом. Для одного из наших продуктов мы - или, скорее, заказчик - хотели порт java... и я сел напротив, к счастью, очень компетентного парня, который делал этот порт (я отказался...). Он пытался сохранять спокойствие... и страдать молча... но боль была, он не мог перестать ругаться после нескольких дней постоянной работы со знаковыми целочисленными значениями, которые ДОЛЖНЫ быть беззнаковыми... Даже писать модульные тесты для эти сценарии болезненны, и я лично считаю, что java было бы лучше, если бы они опускали целые числа со знаком и предлагали просто беззнаковые... по крайней мере, тогда вам не нужно заботиться о расширениях знаков и т.д.и вы все еще можете интерпретировать числа как дополнение до двух.

Это мои 5 центов по этому поводу.

Другие вопросы по тегам