Skip to main content

Command Palette

Search for a command to run...

Беззнаковые целые числа в программировании

Updated
12 min read
A

Backend developer, team leader.

Зачем они нужны?

Это небольшой рассказ о беззнаковых целых числах (unsigned integers) в программировании. С математической точки зрения, это немного странная конструкция, зачем вообще нужны какие-то "урезанные" числа? Но всё-таки для этого есть причины, приведу основные из них:

  1. Расширение доступного диапазона чисел на том же числе бит**. Можно хранить больше значений, если отрицательные вам не нужны. Например, 8-битный знаковый целый тип может хранить значения от -128 до 127, тогда как беззнаковый тип может хранить значения от 0 до 255.
  2. Дополнительный контроль бизнес-логики. Иногда требования приложения не допускают отрицательных значений (например, количество единиц товара, возраст клиента, размер файла и т.д.). Тогда использование беззнаковых типов помогает явно выразить эти ограничения и предотвратить ошибки, связанные с неправильным использованием отрицательных чисел.
  3. Оптимизация производительности. В некоторых архитектурах процессоров операции с беззнаковыми числами могут выполняться быстрее, чем со знаковыми. Кроме того, это подсказка для компилятора или интерпретатора о том, что можно использовать определённые оптимизации.

Как они работают?

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

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

Какие выводы можно сделать?

  1. Беззнаковые числа играют важную роль в программировании, позволяя эффективно использовать память и самодокументировать логику программ.
  2. Важно понимать, что беззнаковые и знаковые числа это просто разные способы интерпретации одного и того же набора бит.
  3. Нужно быть предельно аккуратными при выборе подобного типа данных, понимая ограничения используемого языка и то, как он делает преобразования между типами.

Что ещё почитать?

  1. Wikipedia - Unsigned integer
  2. Wikipedia - Two's complement
  3. Примеры из блога Julia Evans

More from this blog

Храните деньги в ...

Попытаемся в это посте понять как лучше хранить деньги в базах данных и какой тип использовать для работы с ними в коде. А в чём вообще проблем? Что не так с числами с плавающей точкой? Если кратко, то с ними все хорошо, но только они вообще не про...

Apr 14, 202212 min read
Храните деньги в ...

Классы-итераторы и генераторы в Python

В языке программирования Python есть понятия итераторов и генераторов. Оба термина не очень сложные и часто является обычными вопросами для собеседований разработчиков. Но в данной статье хочется разобрать немного примеров как создавать подобные объе...

Jan 16, 20223 min read

z0rr0's blog

8 posts

Backend developer, tech leader.