Беззнаковые целые числа в программировании
Backend developer, team leader.

Зачем они нужны?
Это небольшой рассказ о беззнаковых целых числах (unsigned integers) в программировании. С математической точки зрения, это немного странная конструкция, зачем вообще нужны какие-то "урезанные" числа? Но всё-таки для этого есть причины, приведу основные из них:
- Расширение доступного диапазона чисел на том же числе бит**. Можно хранить больше значений, если отрицательные вам не нужны. Например, 8-битный знаковый целый тип может хранить значения от -128 до 127, тогда как беззнаковый тип может хранить значения от 0 до 255.
- Дополнительный контроль бизнес-логики. Иногда требования приложения не допускают отрицательных значений (например, количество единиц товара, возраст клиента, размер файла и т.д.). Тогда использование беззнаковых типов помогает явно выразить эти ограничения и предотвратить ошибки, связанные с неправильным использованием отрицательных чисел.
- Оптимизация производительности. В некоторых архитектурах процессоров операции с беззнаковыми числами могут выполняться быстрее, чем со знаковыми. Кроме того, это подсказка для компилятора или интерпретатора о том, что можно использовать определённые оптимизации.
Как они работают?
Давайте напишем небольшие примеры, чтобы наглядно посмотреть как работают беззнаковые целые числа.
Пример на Go - программа берёт значения от 0 до 259 и выводит их представление в виде знаковых и беззнаковых чисел, а также их бинарное и шестнадцатеричное представление.
package main
import (
"fmt"
"unsafe"
)
func s2u(s int8) uint8 {
return *(*uint8)(unsafe.Pointer(&s))
}
func main() {
var (
signed int8
unsigned uint8
)
size := unsafe.Sizeof(signed)
fmt.Printf("Size of int8 and uint8: %d byte(s)\n\n", size)
println("+---------------------------------------------------------------------------------+")
println("| Compare signed and unsigned numbers |")
println("+-----+----------+----------+-----+--------+-----------+-----+--------------------+")
println("| # | unsigned | bin | hex | signed | bin | hex | signed as unsigned |")
println("|-----|----------|----------|-----|--------|-----------|-----|--------------------|")
for i := range 260 {
signed = int8(i)
unsigned = uint8(i)
signedAsUnsigned := s2u(signed)
fmt.Printf("| %-3[1]d | %-8[2]d | %-8[2]b | %-3[2]x | %-6[3]d | %-9[3]b | %-3[3]x | %-18[4]b |\n", i, unsigned, signed, signedAsUnsigned)
}
println("+-----+----------+----------+-----+--------+-----------+-----+--------------------+")
}
Вывод будет следующим (я немерено сократил его для наглядности, вставив в удалённые места ...):
Size of int8 and uint8: 1 byte(s)
+---------------------------------------------------------------------------------+
| Compare signed and unsigned numbers |
+-----+----------+----------+-----+--------+-----------+-----+--------------------+
| # | unsigned | bin | hex | signed | bin | hex | signed as unsigned |
|-----|----------|----------|-----|--------|-----------|-----|--------------------|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 2 | 2 | 10 | 2 | 2 | 10 | 2 | 10 |
...
| 126 | 126 | 1111110 | 7e | 126 | 1111110 | 7e | 1111110 |
| 127 | 127 | 1111111 | 7f | 127 | 1111111 | 7f | 1111111 |
| 128 | 128 | 10000000 | 80 | -128 | -10000000 | -80 | 10000000 |
| 129 | 129 | 10000001 | 81 | -127 | -1111111 | -7f | 10000001 |
| 130 | 130 | 10000010 | 82 | -126 | -1111110 | -7e | 10000010 |
...
| 254 | 254 | 11111110 | fe | -2 | -10 | -2 | 11111110 |
| 255 | 255 | 11111111 | ff | -1 | -1 | -1 | 11111111 |
| 256 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 257 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 258 | 2 | 10 | 2 | 2 | 10 | 2 | 10 |
| 259 | 3 | 11 | 3 | 3 | 11 | 3 | 11 |
+-----+----------+----------+-----+--------+-----------+-----+--------------------+
И аналогичный пример на Rust:
fn main() {
let size = std::mem::size_of::<u8>();
println!("Size of i8 and u8: {} byte(s)\n", size);
println!("+---------------------------------------------------------------------------------+");
println!("| Compare signed and unsigned numbers |");
println!("+-----+----------+----------+-----+--------+-----------+-----+--------------------+");
println!("| # | unsigned | bin | hex | signed | bin | hex | signed as unsigned |");
println!("|-----|----------|----------|-----|--------|-----------|-----|--------------------|");
for number in 0..260 {
// cast 32 bit integer to 8 bit one
let unsigned: u8 = number as u8; // unsigned integer 8 bit
let signed: i8 = unsigned as i8; // signed integer 8 bit
let s2u: u8 = signed as u8; // cast signed to unsigned
println!(
"| {0:<3} | {1:<8} | {1:<8b} | {1:<3x} | {2:<6} | {2:<9b} | {2:<3x} | {3:<18b} |",
number, unsigned, signed, s2u,
);
}
println!("+-----+----------+----------+-----+--------+-----------+-----+--------------------+");
}
Вывод очень похож, но:
- В Rust у отрицательных чисел в бинарном и шестнадцатеричном представлении не выводится знак минус.
- В Go для таких же чисел используется дополнительный код с выводом знака минус.
Но важно понимать, что это отличие лишь в представлении. В памяти оба языка хранят одни и те же данные, и в Rust тоже используется дополнительный код.
Size of i8 and u8: 1 byte(s)
+---------------------------------------------------------------------------------+
| Compare signed and unsigned numbers |
+-----+----------+----------+-----+--------+-----------+-----+--------------------+
| # | unsigned | bin | hex | signed | bin | hex | signed as unsigned |
|-----|----------|----------|-----|--------|-----------|-----|--------------------|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 2 | 2 | 10 | 2 | 2 | 10 | 2 | 10 |
...
| 126 | 126 | 1111110 | 7e | 126 | 1111110 | 7e | 1111110 |
| 127 | 127 | 1111111 | 7f | 127 | 1111111 | 7f | 1111111 |
| 128 | 128 | 10000000 | 80 | -128 | 10000000 | 80 | 10000000 |
| 129 | 129 | 10000001 | 81 | -127 | 10000001 | 81 | 10000001 |
| 130 | 130 | 10000010 | 82 | -126 | 10000010 | 82 | 10000010 |
...
| 254 | 254 | 11111110 | fe | -2 | 11111110 | fe | 11111110 |
| 255 | 255 | 11111111 | ff | -1 | 11111111 | ff | 11111111 |
| 256 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 257 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 258 | 2 | 10 | 2 | 2 | 10 | 2 | 10 |
| 259 | 3 | 11 | 3 | 3 | 11 | 3 | 11 |
+-----+----------+----------+-----+--------+-----------+-----+--------------------+
Нужно сделать небольшое отступление о дополнительном коде. В очень упрощенном варианте алгоритм его получения можно описать так (на примере числа -2):
- Как мы видим для программы на Rust, бинарное представление
-2это11111110. - Старший разряд (самая левая цифра) отвечает за знак числа:
0- положительное,1- отрицательное число. - Если старший разряд равен
0, то дополнительный код совпадает с прямым бинарным представлением числа. - Иначе, чтобы получить дополнительный код отрицательного числа, нужно инвертировать все разряды, кроме старшего, получаем
10000001и прибавить1, это будет10000010. Первый бит интерпретируется как знак, а остальные0000010это уже само число2. В итоге получаем-2.
Вернёмся к выводам программ, глядя на них, возникают 2 вопроса:
- Как при одном и том же бинарном представлении одно и то же значение может интерпретироваться компьютером по-разному? Ведь в памяти это идентичные наборы нулей и единиц.
- Как правильно рассчитать знакое значение из беззнакового и наоборот?
Попробуем ответить на 1-й вопрос. Действительно разницы нет, но при компиляции или интерпретации кода, анализатор программы помечает какой тип используется в конкретном месте, в зависимости от этого, набор нулей и единиц выводится по-разному. По сути, информация о типе хранится в условных "метаданных" программы, а не в самих этих данных.
Рассмотрим на примере Go ассемблера такую программу program.go:
package main
func signedLess(a, b int8) bool {
return a < b
}
func unsignedLess(a, b uint8) bool {
return a < b
}
func main() {
var (
signedA int8 = -1
signedB int8 = 2
unsignedA uint8 = 255
unsignedB uint8 = 2
)
println(signedLess(signedA, signedB))
println(unsignedLess(unsignedA, unsignedB))
}
А затем посмотрим на ее ассемблерный вывод:
# GOARCH=arm64
go tool compile -S program.go
main.signedLess STEXT size=32 args=0x8 locals=0x0 funcid=0x0 align=0x0 leaf
0x0000 00000 (program.go:3) TEXT main.signedLess(SB), LEAF|NOFRAME|ABIInternal, $0-8
0x0000 00000 (program.go:3) FUNCDATA $0, gclocals·g5+hNtRBP6YXNjfog7aZjQ==(SB)
0x0000 00000 (program.go:3) FUNCDATA $1, gclocals·g5+hNtRBP6YXNjfog7aZjQ==(SB)
0x0000 00000 (program.go:3) FUNCDATA $5, main.signedLess.arginfo1(SB)
0x0000 00000 (program.go:3) FUNCDATA $6, main.signedLess.argliveinfo(SB)
0x0000 00000 (program.go:3) PCDATA $3, $1
0x0000 00000 (program.go:4) MOVB R0, R2
0x0004 00004 (program.go:4) MOVB R1, R1
0x0008 00008 (program.go:4) CMPW R2, R1
0x000c 00012 (program.go:4) CSET GT, R0
0x0010 00016 (program.go:4) RET (R30)
0x0000 02 1c 40 93 21 1c 40 93 3f 00 02 6b e0 d7 9f 9a ..@.!.@.?..k....
0x0010 c0 03 5f d6 00 00 00 00 00 00 00 00 00 00 00 00 .._.............
main.unsignedLess STEXT size=32 args=0x8 locals=0x0 funcid=0x0 align=0x0 leaf
0x0000 00000 (program.go:7) TEXT main.unsignedLess(SB), LEAF|NOFRAME|ABIInternal, $0-8
0x0000 00000 (program.go:7) FUNCDATA $0, gclocals·g5+hNtRBP6YXNjfog7aZjQ==(SB)
0x0000 00000 (program.go:7) FUNCDATA $1, gclocals·g5+hNtRBP6YXNjfog7aZjQ==(SB)
0x0000 00000 (program.go:7) FUNCDATA $5, main.unsignedLess.arginfo1(SB)
0x0000 00000 (program.go:7) FUNCDATA $6, main.unsignedLess.argliveinfo(SB)
0x0000 00000 (program.go:7) PCDATA $3, $1
0x0000 00000 (program.go:8) MOVBU R0, R2
0x0004 00004 (program.go:8) MOVBU R1, R1
0x0008 00008 (program.go:8) CMPW R2, R1
0x000c 00012 (program.go:8) CSET HI, R0
0x0010 00016 (program.go:8) RET (R30)
0x0000 02 1c 40 d3 21 1c 40 d3 3f 00 02 6b e0 97 9f 9a ..@.!.@.?..k....
0x0010 c0 03 5f d6 00 00 00 00 00 00 00 00 00 00 00 00 .._.............
Как видно выше, несмотря на то, что signedA и unsignedA в памяти хранятся одинаково, операции загрузки данных MOVB / MOVU и установки результата CSET GT / CSET HI различаются. Это происходит потому, что компилятор знает, какой тип используется в каждой функции, и генерирует соответствующий машинный код для выполнения операций с этими типами. Сравнение CMPW R2, R1 будет считать числа отрицательными или положительными в зависимости от старшего бита. Сама операция сравнения тоже выбирается в зависимости от типа: для знаковых чисел используется GT (greater than), а для беззнаковых HI (higher).
Некоторые языки относятся строго к типизации со знаком или без. Например, Go или Rust не даст вам неявно преобразовать знаковое число в беззнаковое и наоборот. Именно поэтому в программах выше использовался вызов с unsafe.Pointer для Go и as для Rust, чтобы явно указать на необходимость изменения типа:
var unsigned uint8 = -1
// cannot use -1 (untyped int constant) as uint8 value in assignment (overflows)
Но и в Go можно допустить ошибку, например, получить бесконечный цикл можно так:
package main
func main() {
var i uint
// ...
for i = 5; i >= 0; i-- {
println(i)
}
}
Именно поэтому, например, Google C++ Style Guide и создатели Java (изначально) скептически относились к unsigned типам для арифметики, рекомендуя их только для битовых масок.
А например, в C или C++ неявное преобразование возможно, причём без предупреждений компилятора, что часто приводит к сложно детектируемым ошибкам:
uint8_t unsigned_number = -1; // неявное преобразование, будет равно 255
Ещё классический пример - это сравнение знакового и беззнакового числа if (-1 > unsigned_number), когда можно неожиданно получить true, так как -1 будет интерпретировано как большое беззнаковое число.
В Java долгое время вообще не было беззнаковых примитивов (кроме char, который 16-битный), потому что создатели языка считали, что это слишком сложно для программистов.
В Python всё ещё более свободно, там нет отдельных типов для знаковых и беззнаковых чисел, есть только int, который может принимать любые значения в пределах доступной памяти. Но как мы уже узнали выше, если данные для представления не отличаются, то как быть, когда нужно из байт всё-таки получить число, ведь оно может быть разным? Для это требуется явно указать, какой тип мы ожидаем получить:
number = b"\xff"
unsigned = int.from_bytes(number, byteorder="big", signed=False) # беззнаковое целое 255
signed = int.from_bytes(number, byteorder="big", signed=True) # знаковое целое -1
Теперь перейдём ко 2-му вопросу, как правильно преобразовать знаковое число в беззнаковое и наоборот. В современных языках обычно используется дополнительный код, о котором мы писали выше, когда 254 (бинарное 11111110) становится -2. Обратный алгоритм тут кстати не нужен, мы просто интерпретируем набор нулей и единиц как есть, учитывая старший бит как часть числа, а не знак - / +.
Ещё один способ посчитать дополнительный код:
- Если число положительное, то беззнаковое и знаковое представление совпадают
- Если число отрицательное, старший бит равен
1, тогда254 - 256 = -2, где256это2^8, то есть максимально возможное значение для 8-битного числа плюс 1.
Можно ещё один пример?
Хочется поделиться примерами, когда неправильная работа с беззаковыми числами привела к ошибкам.
Пусть у нас есть Clickhouse база данных с таблицей вида:
CREATE TABLE test
(
timestamp DateTime,
sign Int8 DEFAULT 1,
value UInt8,
comment String
) ENGINE = MergeTree() ORDER BY timestamp;
Это какие-то события во времени, а так как данные append-only, то есть мы их только добавляем, то для отмены (условное удаление) используется признак sign, который может быть 1 или -1, а само значение value хранится в беззнаковом типе UInt8.
Теперь нам хочется агрегировать события по дням и мы создаем materialized view:
CREATE MATERIALIZED VIEW mv_test
(
`day` Date,
`total` UInt64
)
ENGINE = SummingMergeTree() ORDER BY day
AS
SELECT toStartOfDay(timestamp) AS day,
sum(value * sign) AS total
FROM test
GROUP BY day;
Для total мы выбираем тип UInt64, так как ожидаем, что это сумма событий value Uint8, отмены при этом должны нам дать 0 и не влияют на итоговый результат.
INSERT INTO test
VALUES ('2026-01-01 00:00:01', 1, 1, 'number 1'),
('2026-01-01 00:00:02', 1, 2, 'number 2'),
('2026-01-01 00:00:06', 1, 255, 'number 255, max unsigned 8-bit');
SELECT * FROM mv_test ORDER BY day FORMAT Vertical;
Row 1:
──────
day: 2026-01-01
total: 258
Вроде бы всё хорошо, но лишь до того момента, пока мы не забыли или специально решили, что время отмены не совпадает со временем основного события. Например, запись для '2nd event rollback' попала на следующим день после '2nd event':
INSERT INTO test
VALUES ('2026-01-02 23:59:55', 1, 1, '1st event'),
('2026-01-02 23:59:58', -1, 1, '1st event rollback'),
('2026-01-02 23:59:59', 1, 1, '2nd event'),
('2026-01-03 00:00:02', -1, 1, '2nd event rollback');
SELECT * FROM mv_test ORDER BY day FORMAT Vertical;
Row 1:
──────
day: 2026-01-01
total: 258
Row 2:
──────
day: 2026-01-02
total: 1
Row 3:
──────
day: 2026-01-03
total: 18446744073709551615
Но запрос аналогичный MV mv_test показывает другой результат:
SELECT toStartOfDay(timestamp) AS day,
sum(value * sign) AS total
FROM test
GROUP BY day
ORDER BY day
FORMAT Vertical;
Row 1:
──────
day: 2026-01-01 00:00:00
total: 258
Row 2:
──────
day: 2026-01-02 00:00:00
total: 1
Row 3:
──────
day: 2026-01-03 00:00:00
total: -1
Значение 18446744073709551615 это максимально возможное значение для UInt64 = 2^64 - 1, то есть -1, интерпретированное как беззнаковое число. При этом 2-й запрос показывает правильный результат -1, так как по умолчанию СУБД интерпретирует total как знаковое число.
Похожую проблему мы поймали когда-то в реальном проекте, причём это касалось денег, неожиданно сумма возвратов за день превысила выручку за несколько лет :)
Но так как это лишь интерпретация вывода данных, а физически байты идентичны, то если MV создано на основе отдельной таблицы, достаточно изменить тип колонки через операцию ALTER TABLE. Иначе придётся пересоздавать MV заново и перезаполнять его.
Какие выводы можно сделать?
- Беззнаковые числа играют важную роль в программировании, позволяя эффективно использовать память и самодокументировать логику программ.
- Важно понимать, что беззнаковые и знаковые числа это просто разные способы интерпретации одного и того же набора бит.
- Нужно быть предельно аккуратными при выборе подобного типа данных, понимая ограничения используемого языка и то, как он делает преобразования между типами.

