Предположим, вы программист и у вас есть два числа. Вы хотите узнать, какое из чисел больше. Если оба числа имеют одинаковый тип, то почти в любом языке программирования решение будет тривиальным. Для этой операции обычно даже есть специальный оператор
<=
. Вот пример на 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. В этом формате число состоит из одного бита знака, битов экспоненты и завершающих битов мантиссы. Для
f32
, а , для f64
. Также есть смещение экспоненты , которое равно для f32
и для f64
; оно используется для получения отрицательных экспонент.Чтобы расшифровать число с плавающей запятой (пока забудем о разных
NaN
и бесконечностях), мы сначала смотрим на битов и декодируем три беззнаковых двоичных целых числа , и :Тогда если , значение числа будет равно
а в противном случае
Именно поэтому это называется завершающими битами мантиссы: первая цифра мантиссы определяется экспонентой. Когда поле экспоненты равно нулю (до применения смещения), мантисса начинается с , в противном случае она начинается с . Когда мантисса начинается с , это называется денормализованным числом. Они важны, потому что закрывают пробел между нулём и первым числом с плавающей запятой. Хорошенько понять всё это вам помогут эксперименты с приложением Float Toy.
Неточность
После того, как мы с этим разобрались, нужно сказать следующее: типы с плавающей запятой стандарта IEEE 754 определяют множество точных чисел. В них нет никакой двусмысленности (за исключением двух видов нуля, и ), как нет и потери точности, нечёткости, интервальных представлений и так далее. Например, точно представлено в
f32
значением 0x3f800000
, а следующее по величине число f32
— это 0x3f800001
со значением .Пример на 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
Помните, что значение точно представимо и в
f32
, и в f64
(потому что f32
является строгим подмножеством f64
), однако мы теряем точность, выводя число как f32
и выполняя его парсинг как f64
. Причина в том, что хотя 1.0000001
— это самое короткое десятичное число, которое округляется до в формате чисел с плавающей запятой f32
, в формате f64
оно округляется до .Иронично то, что в данном случае было бы точнее выполнить парсинг как
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
) в языке программирования не существует нативного типа, являющегося надмножеством обоих. Например, можно точно представить как f64
, но не как i64
. А точно представимо в i64
, но не в f64
. Поэтому мы не можем просто преобразовать одно в другое и закончить на этом, однако так поступают очень многие люди. На самом деле, это настолько распространено, что даже ChatGPT научился это делать:Бесполезно просить ChatGPT устранить баг с явным контрпримером, он будет болтать какую-то чушь об f64::EPSILON
и сравнивать разницу с ним.
Приведённая выше тестовая среда показывает, что проверка
x as f64 <= y
не проходит, поскольку мы выяснили, что 9007199254740993 as f64 <= 9007199254740992.0
а это очевидно ошибочно. Проблема в том, что значение
9007199254740993
(то есть ) нельзя представить в f64
и оно округляется до , после чего сравнение выполняется успешно.Корректная реализация i64
<= f64
Чтобы реализовать корректно, нужно выполнить операцию в целочисленной области определения после округления числа с плавающей запятой до ближайшего целочисленного, поскольку для целочисленного мы имеем
Нам не нужно беспокоиться о том, что округление числа с плавающей запятой вверх или вниз до ближайшего целого произойдёт неверно и мы пропустим целое число, поскольку для IEEE 754 функции
floor
/ ceil
точны. Так происходит потому, что в части числовой прямой, где числа с плавающей запятой IEEE 754 являются дробными, целые числа тоже расположены плотно.Но нам всё равно стоит беспокоиться о том, что значение с плавающей запятой не соответствует целочисленному типу. К счастью, когда такое происходит, сравнение выполняется тривиально. К несчастью, целочисленные типы имеют другой интервал в положительной и отрицательной областях определения, поэтому всё равно нужно быть немного осмотрительными, особенно потому что мы не можем сравнивать с (максимальное значение
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
.Обобщение
Отлично, мы можем выполнять сравнение . А как насчёт ? Мы не можем снова использовать ту же реализацию, поменяв порядок аргументов, поскольку их типы различаются. Однако мы можем создать новую реализацию с нуля, применив тот же принцип; при этом придётся использовать
ceil
вместо floor
: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)
MentalBlood
00.00.0000 00:00Статья так то про частный случай интеграции интерфейсов
dimanhursky
00.00.0000 00:00возможно, точность сравнения сильно преувеличена. Кому нужна точность до 15го знака с плавающей запятой?
в радио измерениях обычно 5 значащих оставляют, остальные шум/мусор. Если этого не хватает, значит базу измерений выбрана неправильно.
mayorovp
00.00.0000 00:00Но что если мы хотим строгого неравенства? Теперь наш трюк с floor/ceil добавляет проблемы, связанные с равенством. Решить их можно отдельной проверкой на равенство в области определения целых чисел, а затем проверкой на неравенство в области определения чисел с плавающей запятой
Ага, сразу видно кто не читал Кнута :-)
Можно же воспользоваться тем, что x < f эквивалентно x < ceil(f), а x > f эквивалентно x > floor(f)
domix32
00.00.0000 00:00Downloads all time: 193
Интересно, но за год не так много людей захотело посравнивать числа таким образом. И зависимостью, по крайней мере на crates.io, тоже никто не стал делать.
DirectoriX
00.00.0000 00:00+2Наверно, потому что есть сильно более старый num-cmp у которого загрузок 838 327 и аж 1 зависимость, зато какая — довольно популярная jsonschema.
Gryphon88
00.00.0000 00:00+2Ой. C++ автоматически преобразовал -1 в unsigned int, из-за чего значение превратилось в максимальное значение типа (которое очевидно больше 1). Но, по крайней мере, современный компилятор по умолчанию ведь предупреждает о таких вещах? [...] Ну, по крайней мере, он не выполняет втихомолку ошибочную компиляцию…
Type promotion полностью определен в стандарте, что не делает его менее популярным способом стрельбы по ногам.
AlexeyK77
00.00.0000 00:00+2в дополнение еще на уроках информатики в советской школе рассказывали, что вещественные числа нельзя сравнивать напрямую оператором равенства. Как минимум должна использоваться конструкция если (a-b)<epsilon то тогда значения считаются условно равными. Эпсилон задавался уже под конкретную задачу.
Gryphon88
00.00.0000 00:00Не подскажете, в каких языках есть удобная реализация fixed point? Обычно float не нужен, если не измеряем расстояние между атомами и между звездами в одной задаче, но постоянно держать в голове, сколько бит отвели на дробную часть лениво.
thevlad
00.00.0000 00:00-1На любом языке, где есть перегрузка соответствующих операторов. А на плюсах наверняка можно получить и около нулевой оверхед.
Refridgerator
00.00.0000 00:00+1Fixed point от неявных округлений вас не спасёт. Чтобы не переживать по этому поводу, обычно используются рациональные числа над BigInteger. В расте вроде есть, в других не менее популярных языках наверняка тоже готовые реализации можно найти.
vabka
00.00.0000 00:00На счёт fixed point не уверен, но в том же C# из коробки есть decimal, с которым удобно с десятичными дробями работать с достаточно высокой для денег точностью.
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
enkryptor
00.00.0000 00:00+3предлагаемое решение ужасно. Нет никаких причин для паники. Наиболее эффективное решение здесь — преобразовать оба значения
Конечно, глупо спорить с переводом, тем не менее автор оригинальной статьи слишком категоричен в своих суждениях. Если бы существовало одно единственное лучшее решение — оно бы и было реализовано в большинстве ЯП, вытеснив все остальные. На практике мы видим, что это не так.
Rust умышленно следует парадигме т.н. сильной типизации, при которой неявное конвертирование типов запрещено. В этом есть своя логика — ведь далеко не все операции со значениями разных типов имеют смысл. Например, нельзя сравнивать количество часов и количество километров, или прибавлять одно к другому (а вот делить — можно).
Rust идёт дальше и вводит понятие единиц измерения (measurements), позволяя добавить ограничения даже на значения одного и того же типа. Что уж говорить про разные типы: когда в программе встречается сравнение знакового и беззнакового целого — это с большой вероятностью ошибка программиста. Если же это делается умышленно, то это должно быть отражено в коде через явное приведение типов.
vabka
00.00.0000 00:00+1Rust идёт дальше и вводит понятие единиц измерения (measurements)
Мне кажется, вы либо имеете в виду newtype, которые всё-таки являются прозрачной обёрткой, а не "ограничениями для одного и того же типа", либо имеете в виду F#, у которого такая фича и правда есть (и даже делить километры на часы можно)
AndrewFilinsky
00.00.0000 00:00> "Компилятор должен сам делать всё правильно, а не заставлять людей выполнять вручную преобразования, которые могут быть и ошибочными, или, хуже того, втихомолку генерировать неверный код."
Например, компилятор Delphi делает это настолько педантично, что иногда приходится сильно поприседать, чтобы исключить генерацию в коде этих специальных преобразований, когда заранее известно, что коллизий при несовпадении типов не будет. Приходится аккуратно и точно использовать типы данных, чтобы код не содержал лишнего.
OldFisher
Вот так вот люди стараются, наводят строгости, чтобы помочь программистам устранить путаницу в типах и проистекающие из этого ошибки разной степени катастрофичности, а потом приходит эдакий мистер Секонд: "Го сравнивать всё со всем, я создал!"
(рекомендация врача: комментарий следует принимать с немножечком несерьёзности)