4.5 – Целочисленные типы данных без знака, и почему их следует избегать

Добавлено 29 апреля 2021 в 13:08

Целочисленные типы данных без знака

В предыдущем уроке (4.4 – Целочисленные типы данных со знаком) мы рассмотрели целочисленные типы со знаком, которые представляют собой набор типов, которые могут содержать положительные и отрицательные целые числа, включая 0.

C++ также поддерживает беззнаковые целочисленные типы. Беззнаковые целочисленные значения – это целые числа, которые могут содержать только неотрицательные значения.

Определение беззнаковых целочисленных значений

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

unsigned short us;
unsigned int ui;
unsigned long ul;
unsigned long long ull;

Диапазоны значений целочисленных типов без знака

Диапазон 1-байтового беззнакового целочисленного типа составляет от 0 до 255. Сравните это с диапазоном 1-байтового целочисленного типа со знаком, который составляет от -128 до 127. Оба они могут хранить 256 различных значений, но целочисленные значения со знаком используют половину своего диапазона для отрицательных чисел, тогда как целочисленные значения без знака могут хранить в два раза большие положительные числа.

Ниже показана таблица, показывающая диапазоны беззнаковых целочисленных типов:

Размер / ТипДиапазон
1 байт без знакаот 0 до 255
2 байт без знакаот 0 до 65 535
4 байт без знакаот 0 до 4 294 967 295
8 байт без знакаот 0 до 18 446 744 073 709 551 615

Диапазон беззнаковой переменной размером n бит составляет от 0 до (2n)-1.

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

Напоминание относительно значений со знаком и без знака

Начинающие программисты иногда путаются между значениями со знаком и без знака. Простой способ запомнить разницу: чтобы отличить отрицательные числа от положительных, мы используем знак минус. Если знак не указан, мы предполагаем, что число положительное. Следовательно, целочисленное значение со знаком (целочисленный тип со знаком) может различать положительные и отрицательные числа. Целочисленное значение без знака (целочисленный тип без знака) предполагает, что все значения положительны.

Переполнение целочисленных значений без знака

Что произойдет, если мы попытаемся сохранить число 280 (для представления которого требуется 9 бит) в 1-байтовом (8-битном) целочисленном значении без знака? Ответ – переполнение.

Примечание автора


Как ни странно, в стандарте C++ прямо говорится, что «вычисление с использованием беззнаковых операндов никогда не может переполниться». Это противоречит общему мнению программистов о том, что целочисленное переполнение охватывает случаи как со знаком, так и без знака. Учитывая, что большинство программистов рассмотрело бы этот случай как переполнение, мы будем называть его переполнением, несмотря на утверждения C++ об обратном.

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

Число 280 слишком велико, чтобы поместиться в наш 1-байтовый диапазон от 0 до 255. Значение, которое на 1 больше максимального числа для этого типа данных, равно 256. Следовательно, мы делим 280 на 256, получая остаток 24. Остаток 24 – это то, что в итоге сохранится.

Вот еще один способ размышления о том же. Любое число, превышающее наибольшее число, которое может быть представлено заданным типом данных, просто «оборачивается» (или совершает циклический переход). Число 255 находится в диапазоне 1-байтового целочисленного типа, поэтому 255 сохранится нормально. А число 256 находится за пределами диапазона, поэтому оно оборачивается до значения 0. 257 оборачивается до значения 1. 280 оборачивается до значения 24.

Давайте посмотрим на это, используя 2-байтовые целые числа:

#include <iostream>
 
int main()
{
    unsigned short x{ 65535 }; // максимальное возможное 16-битное беззнаковое значение
    std::cout << "x was: " << x << '\n';
 
    x = 65536; // 65536 выходит за пределы нашего диапазона, поэтому мы получаем циклический переход
    std::cout << "x is now: " << x << '\n';
 
    x = 65537; // 65537 выходит за пределы нашего диапазона, поэтому мы получаем циклический переход
    std::cout << "x is now: " << x << '\n';
 
    return 0;
}

Как вы думаете, каким будет результат работы этой программы?

x was: 65535
x is now: 0
x is now: 1

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

#include <iostream>
 
int main()
{
    unsigned short x{ 0 }; // наименьшее возможное 2-байтовое беззнаковое значение
    std::cout << "x was: " << x << '\n';
 
    x = -1; // -1 находится вне нашего диапазона, поэтому мы получаем циклический переход
    std::cout << "x is now: " << x << '\n';
 
    x = -2; // -2 находится вне нашего диапазона, поэтому мы получаем циклический переход
    std::cout << "x is now: " << x << '\n';
 
    return 0;
}
x was: 0
x is now: 65535
x is now: 65534

Приведенный выше код в некоторых компиляторах вызывает предупреждение, поскольку компилятор обнаруживает, что целочисленный литерал выходит за пределы допустимого диапазона для данного типа. Если вы всё равно хотите скомпилировать этот код, временно отключите параметр «Treat warnings as errors» (обрабатывать предупреждения как ошибки).

В качестве отступления...


Многие заметные ошибки в истории видеоигр произошли из-за поведения циклического перехода целочисленных значений без знака. В аркадной игре Donkey Kong невозможно пройти уровень 22 из-за бага переполнения, из-за которого у пользователя недостаточно бонусного времени для завершения уровня.

В компьютерной игре Civilization Ганди был известен тем, что часто первым использовал ядерное оружие, что, похоже, противоречит ожидаемой от него пассивности. Игроки полагали, что это результат агрессии Ганди, изначально установленной на 1, но, если он выберет демократическое правительство, он получит модификатор -2. Это приведет к тому, что его агрессия переполнится до значения 255, что сделает его максимально агрессивным! Однако совсем недавно Сид Мейер (автор игры) пояснил, что на самом деле это не так.

Споры по поводу чисел без знака

Многие разработчики (и некоторые крупные компании, например, Google) считают, что разработчикам следует избегать целочисленных типов без знака.

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

Во-первых, рассмотрим вычитание двух беззнаковых чисел, таких как 3 и 5. 3 минус 5 равно -2, но -2 не может быть представлено как беззнаковое число.

#include <iostream>
 
int main()
{
	unsigned int x{ 3 };
	unsigned int y{ 5 };
 
	std::cout << x - y << '\n';
	return 0;
}

На машине автора эта, казалось бы, невинно выглядящая программа дает следующий результат:

4294967294

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

Во-вторых, непредвиденное поведение может возникнуть при смешивании целочисленных значений со знаком и без знака. В приведенном выше примере, даже если один из операндов (x или y) будет со знаком, другой операнд (беззнаковый) приведет к тому, что операнд со знаком будет преобразован в целочисленное значение без знака, и будет получено такое же поведение!

Рассмотрим следующий фрагмент:

void doSomething(unsigned int x)
{
    // какой-то код, выполняющийся x раз

    std::cout << "x is " << x << '\n';
}
 
int main()
{
    doSomething(-1);
 
    return 0;
}

Автор doSomething() ожидал, что эту функцию будут вызывать только с положительными числами. Но вызывающий передает -1. Что происходит в этом случае?

Аргумент со знаком -1 неявно преобразуется в беззнаковый параметр. -1 не входит в диапазон беззнаковых чисел, поэтому выполняется циклический перенос до некоторого большого числа (вероятно, 4294967295). Тогда ваша программа «взбесится». Хуже того, нет хорошего способа предотвратить это состояние. C++ может свободно преобразовывать числа со знаком и без знака, но не проверяет диапазон, чтобы убедиться, что вы не переполняете свой тип данных.

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

Некоторые современные языки программирования (например, Java) и фреймворки (например, .NET) либо не включают в себя беззнаковые типы, либо ограничивают их использование.

Начинающие программисты часто используют целочисленные типы без знака для представления неотрицательных данных или для того, чтобы воспользоваться дополнительно расширенным диапазоном. Бьярн Страуструп, разработчик C++, сказал: «Использование unsigned int вместо int для получения еще одного бита для представления положительных целых чисел почти никогда не бывает хорошей идеей».

Предупреждение


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

Не избегайте отрицательных чисел с помощью беззнаковых типов. Если вам нужен больший диапазон, чем предлагает числовой тип со знаком, используйте один из целочисленных типов гарантированной ширины, показанных в следующем уроке (4.6 – Целочисленные типы фиксированной ширины и size_t).

Если вы всё же используете беззнаковые числа, по возможности избегайте смешивания чисел со знаком и без знака.

Так где же стоит использовать беззнаковые числа?

В C++ всё же есть несколько случаев, когда можно (или необходимо) использовать беззнаковые числа.

Во-первых, числа без знака предпочтительнее при работе с битами (рассматривается в главе O (это заглавная буква «о», а не «0»).

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

Также обратите внимание, что, если вы разрабатываете для встраиваемой системы (например, Arduino) или какого-либо другой системы с ограничениями процессора/памяти, использование чисел без знака более распространено и приемлемо (а в некоторых случаях неизбежно) по соображениям производительности.

Теги

C++ / CppintegerunsignedДля начинающихОбучениеПрограммированиеЦелочисленный тип данных

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

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.