Предположим, вы программист и у вас есть два числа. Вы хотите узнать, какое из чисел больше. Если оба числа имеют одинаковый тип, то почти в любом языке программирования решение будет тривиальным. Для этой операции обычно даже есть специальный оператор <=. Вот пример на Python:

>>> "120" <= "1132"
False

Сравнение двух чисел на Brainfuck оставим в качестве упражнения для читателя.

Ой. Ну, строго говоря, это строки, а не числа, а строки обычно сортируются лексикографически. Но это всё-таки числа, хотя и представленные в виде строк. Это может показаться глупым, но такая проблема очень распространена в интерфейсах пользователя, например, в списках файлов. Именно поэтому нужно отбивать числовые имена файлов нулями (frame-00001.png) или использовать описания, сохраняющие лексикографический порядок, например, ISO 8601 для дат.

Впрочем, я отклонился от темы. Предположим, числа действительно представлены числовыми типами. Тогда всё просто и <= отлично работает:

>>> 120 <= 1132
True

Но так ли это?

Сравнения разных целочисленных типов


Что если два сравниваемых числа не имеют одного типа? Первым делом мы можем всё равно попробовать использовать <=. Вот пример на C++:

std::cout << ((-1) <= 1u) << "\n";  // Выводит 0.

Ой. C++ автоматически преобразовал -1 в unsigned int, из-за чего значение превратилось в максимальное значение типа (которое очевидно больше 1). Но, по крайней мере, современный компилятор по умолчанию ведь предупреждает о таких вещах?

$ g++ -std=c++20 main.cpp && ./a.out
0

Любопытно было бы прочитать исследование о том, сколько багов возникло из-за сравнения разных целочисленных типов. Не удивился бы, что их довольно много, особенно на C.

Отлично. Ещё одна причина не забывать включать предупреждения (-Wall -pedantic).

Попробуем Rust:

let x: i32 = -1; let y: u32 = 1;
println!("{:?}", x <= y);

error[E0308]: mismatched types
 --> src/main.rs:4:22
  |
4 | println!("{:?}", x <= y);
  |                      ^ expected `i32`, found `u32`
  |
help: you can convert a `u32` to an `i32` and panic if the converted value doesn't fit

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

println!("{:?}", (x as i64) <= (y as i64)); // Выводит true.

Но что если типа, являющегося надмножеством обоих, не существует? По крайней мере, для типов integer это не проблема. Например, в Rust нет типа, являющегося надмножеством i128 и u128. Но мы знаем, что i128 при неотрицательности соответствует u128, а если он отрицателен, то всегда меньше:

fn less_eq(x: i128, y: u128) -> bool {
    if x <= 0 { true } else { x as u128 <= y }
}

Всё это может вызывать ошибки, да и попросту раздражает. Сравнения между разными типами integer всегда малозатратны, поэтому я не вижу веских причин того, что компилятор не генерирует показанный выше код автоматически. Например, на Apple ARM показанное выше для i64 <= u64 компилируется в три команды:

example::less_eq:
        cmp     x0, #1
        ccmp    x0, x1, #0, ge
        cset    w0, ls
        ret

Компилятор должен сам делать всё правильно, а не заставлять людей выполнять вручную преобразования, которые могут быть и ошибочными, или, хуже того, втихомолку генерировать неверный код. По крайней мере, в C++20 появились новые функции сравнения integer для сравнений между разными типами integer, но обычные операторы сравнения по-прежнему столь же опасны.

Числа с плавающей запятой точны


Прежде чем углубляться в сравнения разных типов с плавающей запятой, надо вкратце вспомнить, что же такое плавающая запятая. Когда я говорю о плавающей запятой, то подразумеваю двоичные числа с плавающей запятой, определяемые стандартом IEEE 754, в частности, binary32 (также известный как f32 или float) и binary64 (также известный как f64 или double). Последняя версия стандарта имеет DOI 10.1109/IEEESTD.2019.8766229.

А вы знали о существовании Sci-Hub? Это важный проект, устраняющий преграды и paywall перед человеческим знанием. Обычно, если у вас есть DOI-ссылка, то документ можно найти всего за пару кликов! (В некоторых юрисдикциях это может быть незаконным.)

Вспомним IEEE 754


Этот формат чисел с плавающей запятой имеет множество странностей, но с большой вероятностью машина, на которой вы читаете мою статью, использует его в качестве нативной реализации floating point. В этом формате число состоит из одного бита знака, $w$ битов экспоненты и $t$завершающих битов мантиссы. Для f32 $w = 8$, а $t = 23$, для f64 $w = 11, t = 52$. Также есть смещение экспоненты $b$, которое равно $127$ для f32 и $1023$ для f64; оно используется для получения отрицательных экспонент.

Чтобы расшифровать число с плавающей запятой (пока забудем о разных NaN и бесконечностях), мы сначала смотрим на $1 + w + t$ битов и декодируем три беззнаковых двоичных целых числа $s$, $e$ и $m$:


Тогда если $e = 0$, значение числа будет равно

$\begin{equation}f = {(-1)}^s \times 2^{e - b + 1} \times (0 + m / 2^t),\end{equation}$


а в противном случае

$\begin{equation}f = {(-1)}^s \times 2^{e - b} \times (1 + m / 2^t).\end{equation}$


Именно поэтому это называется завершающими битами мантиссы: первая цифра мантиссы определяется экспонентой. Когда поле экспоненты равно нулю (до применения смещения), мантисса начинается с $0$, в противном случае она начинается с $1$. Когда мантисса начинается с $0$, это называется денормализованным числом. Они важны, потому что закрывают пробел между нулём и первым числом с плавающей запятой. Хорошенько понять всё это вам помогут эксперименты с приложением Float Toy.

Неточность


После того, как мы с этим разобрались, нужно сказать следующее: типы с плавающей запятой стандарта IEEE 754 определяют множество точных чисел. В них нет никакой двусмысленности (за исключением двух видов нуля, $+0$ и $-0$), как нет и потери точности, нечёткости, интервальных представлений и так далее. Например, $1.0$точно представлено в f32 значением 0x3f800000, а следующее по величине число f32 — это 0x3f800001 со значением ${(-1)}^0 \times 2^0 \times (1 + 1 / 2^{23}) = 1.00000011920928955078125$.

Пример на Rust:

println!("{}", f32::from_bits(0x3f800001));
1.0000001

Ой. Я что, соврал? Нет, лжёт именно Rust:

let full = format!("{:.1000}", f32::from_bits(0x3f800001));
println!("{}", full.trim_end_matches('0'));
1.00000011920928955078125

Это не баг и не случайность. Rust (и, на самом деле, большинство языков программирования) пытается выводить как можно меньше цифр, чтобы гарантировать корректность round-trip. И в самом деле:

println!("0x{:x}", "1.0000001".parse::<f32>().unwrap().to_bits());
0x3f800001

Однако это имеет неприятные последствия, если вы затем парсите число как тип с более высокой точностью:

"1.0000001".parse::<f64>().unwrap() == 1.00000011920928955078125
false

Помните, что значение $1.00000011920928955078125$ точно представимо и в f32, и в f64 (потому что f32 является строгим подмножеством f64), однако мы теряем точность, выводя число как f32 и выполняя его парсинг как f64. Причина в том, что хотя 1.0000001 — это самое короткое десятичное число, которое округляется до $1.00000011920928955078125$ в формате чисел с плавающей запятой f32, в формате f64 оно округляется до $1.0000001000000000583867176828789524734020233154296875$.

Иронично то, что в данном случае было бы точнее выполнить парсинг как f32 и преобразовать значение в f64, потому что Rust гарантирует корректность round-trip:

"1.0000001".parse::<f32>().unwrap() as f64 == 1.00000011920928955078125
true

То есть f32 -> f64 выполняется без потерь, как и f32 -> String -> f32 -> f64. Однако при f32 -> String -> f64 точность теряется.

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

Учитывая объёмы незаметного округления, я не буду винить вас, если у вас сложилось впечатление о числах с плавающей запятой как о «нечётких». Они создают иллюзию существования типа «вещественного числа». Однако в реальности внутри языка значения являются точным конечным множеством чисел.

Сравнения разных типов с плавающей запятой


Почему я делаю такой большой упор на точность чисел с плавающей запятой стандарта IEEE 754? Потому что она означает (за исключением NaN), что сравнение целых чисел и чисел с плавающей запятой тоже определено чрезвычайно точно и без всяких двусмысленностей. В конечном итоге, все они являются точными числами, помещёнными на прямую вещественных чисел.

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

/// x <= y
fn is_less_eq(x: i64, y: f64) -> bool {
    todo!()
}

Если вы хотите попробовать сделать это на Rust, то я написал набор тестов (не исчерпывающий) в песочнице Rust. Можно вставить туда свою реализацию, которая покажет вам, при каких входных данных функция даёт сбой. Если вы хотите попробовать сделать это на другом языке, то помните, что язык программирования может лгать вам по умолчанию! Например:

let x: i64 = 1 << 58;
let y: f64 = x as f64; // 2^58, можно представить в точном виде.
println!("{x} <= {y}: {}", x as f64 <= y);
288230376151711744 <= 288230376151711740: true

Это может показаться плохим сравнением или преобразованием из i64 в f64, но на самом деле это не так. Проблема целиком связана с округлением при форматировании.

Основная сложность заключается в том, что для многих сочетаний типов (например, i64 и f64) в языке программирования не существует нативного типа, являющегося надмножеством обоих. Например, $2^{1000}$ можно точно представить как f64, но не как i64. А $2^{53} + 1$ точно представимо в i64, но не в f64. Поэтому мы не можем просто преобразовать одно в другое и закончить на этом, однако так поступают очень многие люди. На самом деле, это настолько распространено, что даже ChatGPT научился это делать:


Бесполезно просить ChatGPT устранить баг с явным контрпримером, он будет болтать какую-то чушь об f64::EPSILON и сравнивать разницу с ним.

Приведённая выше тестовая среда показывает, что проверка x as f64 <= y не проходит, поскольку мы выяснили, что

9007199254740993 as f64 <= 9007199254740992.0

а это очевидно ошибочно. Проблема в том, что значение 9007199254740993 (то есть $2^{53}+1$) нельзя представить в f64 и оно округляется до $2^{53}$, после чего сравнение выполняется успешно.

Корректная реализация i64 <= f64


Чтобы реализовать $i \leq f$ корректно, нужно выполнить операцию в целочисленной области определения после округления числа с плавающей запятой до ближайшего целочисленного, поскольку для целочисленного $i$ мы имеем

$i \leq f \iff i \leq \lfloor f \rfloor$


Нам не нужно беспокоиться о том, что округление числа с плавающей запятой вверх или вниз до ближайшего целого произойдёт неверно и мы пропустим целое число, поскольку для IEEE 754 функции floor / ceil точны. Так происходит потому, что в части числовой прямой, где числа с плавающей запятой IEEE 754 являются дробными, целые числа тоже расположены плотно.

Но нам всё равно стоит беспокоиться о том, что значение с плавающей запятой не соответствует целочисленному типу. К счастью, когда такое происходит, сравнение выполняется тривиально. К несчастью, целочисленные типы имеют другой интервал в положительной и отрицательной областях определения, поэтому всё равно нужно быть немного осмотрительными, особенно потому что мы не можем сравнивать с $2^{63} - 1$ (максимальное значение i64) в области определения с плавающей запятой.

fn is_less_eq(x: i64, y: f64) -> bool {
    if y.is_nan() { return false; }
    if y >= 9223372036854775808.0 { // 2^63
        true // y всегда больше.
    } else if y >= -9223372036854775808.0 { // -2^63
        x <= y.floor() as i64  // y находится в [-2^63, 2^63)
    } else {
        false // y всегда меньше.
    }
}

Возможно, вы подумаете, что можно обойтись без floor, поскольку сразу после него мы выполняем преобразование в целое число. Увы, as i64 выполняет округление в сторону нуля, но нам нужно выполнять в сторону отрицательной бесконечности, в противном случае мы заявим, что -1 <= -1.5.

Обобщение


Отлично, мы можем выполнять сравнение $i \leq f$. А как насчёт $i \geq f$? Мы не можем снова использовать ту же реализацию, поменяв порядок аргументов, поскольку их типы различаются. Однако мы можем создать новую реализацию с нуля, применив тот же принцип; при этом придётся использовать ceil вместо floor:

$i \geq f \iff i \geq \lceil f \rceil$


fn is_greater_eq(x: i64, y: f64) -> bool {
    if y.is_nan() { return false; }
    if y >= 9223372036854775808.0 { // 2^63
        false // y всегда больше.
    } else if y >= -9223372036854775808.0 { // -2^63
        x >= y.ceil() as i64  // y находится в [-2^63, 2^63)
    } else {
        true // y всегда меньше.
    }
}

Но что если мы хотим строгого неравенства? Теперь наш трюк с floor/ceil добавляет проблемы, связанные с равенством. Решить их можно отдельной проверкой на равенство в области определения целых чисел, а затем проверкой на неравенство в области определения чисел с плавающей запятой:

fn is_less(x: i64, y: f64) -> bool {
    if y.is_nan() { return false; }
    if y >= 9223372036854775808.0 { // 2^63
        true
    } else if y >= -9223372036854775808.0 { // -2^63
        let yf = y.floor(); // y находится в [-2^63, 2^63)
        let yfi = yf as i64;
        x < yfi || x == yfi && yf < y
    } else {
        false
    }
}

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

Заключение


Итак, насколько сложным может быть упорядочивание чисел? Я бы сказал, что довольно-таки сложным, если язык, с которым вы работаете, не поддерживает его нативно. Судя по тому, как разные люди решали задачу корректной реализации is_less_eq, никто не может сделать это правильно с первой попытки. И это после того, как им чётко сказали, что сложность в том, чтобы сделать это корректно для всех входных данных. Цитата из стандартной библиотеки Python: «сравнение — это настоящий кошмар».

Из всех рассмотренных мной языков с чётким разделением на типы целых чисел и чисел с плавающей запятой это правильно реализовано в Python, Julia, Ruby и Go. Отличная работа! Некоторые языки по умолчанию предупреждают или просто не разрешают выполнять сравнения между разными типами, однако, например, Kotlin просто говорит, что 9007199254740993 <= 9007199254740992.0 является true.

Я создал для Rust крейт num-ord, позволяющий корректно сравнивать два любых встроенных числовых типа. Но мне бы хотелось, чтобы в этом и других языках всё делалось правильно нативно. Потому что если этого не будет, то людям придётся решать такую задачу самостоятельно, на что они чаще всего неспособны.

Комментарии (21)


  1. OldFisher
    00.00.0000 00:00
    +8

    Я создал для Rust крейт num-ord, позволяющий корректно сравнивать два любых встроенных числовых типа.

    Вот так вот люди стараются, наводят строгости, чтобы помочь программистам устранить путаницу в типах и проистекающие из этого ошибки разной степени катастрофичности, а потом приходит эдакий мистер Секонд: "Го сравнивать всё со всем, я создал!"

    (рекомендация врача: комментарий следует принимать с немножечком несерьёзности)


  1. MentalBlood
    00.00.0000 00:00

    Статья так то про частный случай интеграции интерфейсов


    1. dimanhursky
      00.00.0000 00:00

      возможно, точность сравнения сильно преувеличена. Кому нужна точность до 15го знака с плавающей запятой?

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


  1. mayorovp
    00.00.0000 00:00

    Но что если мы хотим строгого неравенства? Теперь наш трюк с floor/ceil добавляет проблемы, связанные с равенством. Решить их можно отдельной проверкой на равенство в области определения целых чисел, а затем проверкой на неравенство в области определения чисел с плавающей запятой

    Ага, сразу видно кто не читал Кнута :-)


    Можно же воспользоваться тем, что x < f эквивалентно x < ceil(f), а x > f эквивалентно x > floor(f)


    1. paluke
      00.00.0000 00:00

      ерунду написал, удалил...


  1. domix32
    00.00.0000 00:00

    Downloads all time: 193

    Интересно, но за год не так много людей захотело посравнивать числа таким образом. И зависимостью, по крайней мере на crates.io, тоже никто не стал делать.


    1. DirectoriX
      00.00.0000 00:00
      +2

      Наверно, потому что есть сильно более старый num-cmp у которого загрузок 838 327 и аж 1 зависимость, зато какая — довольно популярная jsonschema.


      1. Deosis
        00.00.0000 00:00
        +1

        Только не зависимость, а потребитель.


        1. DirectoriX
          00.00.0000 00:00

          Ох ты ж… Извиняюсь, напутал термины пока оформлял мысль.


  1. Gryphon88
    00.00.0000 00:00
    +2

    Ой. C++ автоматически преобразовал -1 в unsigned int, из-за чего значение превратилось в максимальное значение типа (которое очевидно больше 1). Но, по крайней мере, современный компилятор по умолчанию ведь предупреждает о таких вещах? [...] Ну, по крайней мере, он не выполняет втихомолку ошибочную компиляцию…
    Type promotion полностью определен в стандарте, что не делает его менее популярным способом стрельбы по ногам.


  1. AlexeyK77
    00.00.0000 00:00
    +2

    в дополнение еще на уроках информатики в советской школе рассказывали, что вещественные числа нельзя сравнивать напрямую оператором равенства. Как минимум должна использоваться конструкция если (a-b)<epsilon то тогда значения считаются условно равными. Эпсилон задавался уже под конкретную задачу.


    1. vabka
      00.00.0000 00:00

      По тому в статье рассматривается не строгое равенство, а "меньше либо равно"


  1. Gryphon88
    00.00.0000 00:00

    Не подскажете, в каких языках есть удобная реализация fixed point? Обычно float не нужен, если не измеряем расстояние между атомами и между звездами в одной задаче, но постоянно держать в голове, сколько бит отвели на дробную часть лениво.


    1. thevlad
      00.00.0000 00:00
      -1

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


    1. Refridgerator
      00.00.0000 00:00
      +1

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


    1. vabka
      00.00.0000 00:00

      На счёт fixed point не уверен, но в том же C# из коробки есть decimal, с которым удобно с десятичными дробями работать с достаточно высокой для денег точностью.


  1. HemulGM
    00.00.0000 00:00
    +2

    А Delphi прекрасно справился со сравнением Signed и Unsigned переменных.

    writeln((-1) <= Cardinal(1)); //TRUE

    И вывел предупреждение
    W1022 Comparison always evaluates to True
    и
    W1023 Comparing signed and unsigned types - widened both operands


  1. enkryptor
    00.00.0000 00:00
    +3

    предлагаемое решение ужасно. Нет никаких причин для паники. Наиболее эффективное решение здесь — преобразовать оба значения

    Конечно, глупо спорить с переводом, тем не менее автор оригинальной статьи слишком категоричен в своих суждениях. Если бы существовало одно единственное лучшее решение — оно бы и было реализовано в большинстве ЯП, вытеснив все остальные. На практике мы видим, что это не так.

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

    Rust идёт дальше и вводит понятие единиц измерения (measurements), позволяя добавить ограничения даже на значения одного и того же типа. Что уж говорить про разные типы: когда в программе встречается сравнение знакового и беззнакового целого — это с большой вероятностью ошибка программиста. Если же это делается умышленно, то это должно быть отражено в коде через явное приведение типов.


    1. AxeFizik
      00.00.0000 00:00
      +2

      Rust идёт дальше и вводит понятие единиц измерения (measurements)

      А где он это вводит? Гугл по запросу Rust measurements выдаёт, только один крейт. Или вы про возможность обернуть число в new type?


    1. vabka
      00.00.0000 00:00
      +1

      Rust идёт дальше и вводит понятие единиц измерения (measurements)

      Мне кажется, вы либо имеете в виду newtype, которые всё-таки являются прозрачной обёрткой, а не "ограничениями для одного и того же типа", либо имеете в виду F#, у которого такая фича и правда есть (и даже делить километры на часы можно)


  1. AndrewFilinsky
    00.00.0000 00:00

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

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