#Введение
В этой статье предполагается что вы знакомы с языком Rust, и знаете что такое алгебраические типы данных (ADT).
Сравните эти два примера кода:
function something(a) {
if (typeof a === "number") {
return a*3 + 2;
} else if (typeof a === "string") {
return 0;
}
}
fn something(a: f64) -> f64 {
if a.is_nan() {
0.0
} else if !a.is_finite() {
1.0
} else if a == 0.0 {
1.0
} else {
1 / a.abs()
}
}
Казалось, бы, два совершенно разных языка, а код выглядит так одинаково: всё валидируется (кто не в теме, надо парсить, а не валидировать). Да, всё дело в том, что вещественные числа - это, по сути, динамическая типизация на уровне процессора. У нас в переменной вещественного числа может храниться множество состояний, которые мы должны проверять, если хотим узнать что там хранится. Так же там присутствует неявное приведение типов из "разных" категорий, например 1.0 + NaN = NaN
.
Что люди сделали с динамической типизацией в JavaScript? Они придумали TypeScript. И стало хорошо. Получается то же самое можно сделать с вещественными числами?
#NaN
Самая базовая проблема вещественных чисел - это NaN
. Он обладает очень противным свойством: не равен самому себе.
Из-за этого мы в Rust'е не можем хранить вещественные числа в дереве, сортировать массив с ними, то есть реализовывать трейты Eq
, Ord
.
Если вытащить из вещественного числа NaN
, то оно сразу становится хорошим. Схематически это можно обозначить так:
enum Float {
NaN,
NotNanFloat(NotNanFloat),
}
enum NotNanFloat { /* ... */ }
impl Eq for NotNanFloat { /* ... */ }
impl Ord for NotNanFloat { /* ... */ }
И мы можем записать в массиве, что храним только NotNanFloat
.
Когда я писал github:confidence, мне очень не хватало возможности матчиться по такой структуре данных, или говорить что я принимаю только NotNanFloat
.
Правда, появляется проблема, что некоторые операции над NotNanFloat
позволяют прийти к NaN
, например: умножение бесконечности на ноль, деление на ноль. Получается, нам после любых арифметических вычислений нужно приводить тип из более широкого Float
обратно к NotNanFloat
:
let a: NotNanFloat = ...;
let b: NotNanFloat = ...;
let c: Float = a * b / (a + b);
let d: NotNanFloat = c.try_into().unwrap();
С одной стороны это даже хорошо, потому что мы не будем хранить фигню у себя в массивах, и в конце не получим что у нас огромная матрица перемножалась 100 раз с NaN
'ами, а получим ошибку намного раньше.
#Продолжение безумия
Можно пойти дальше и ещё сильнее разделить вещественные числа:
enum NotNanFloat {
InfiniteFloat(InfiniteFloat),
FiniteFloat(FiniteFloat),
}
enum InfiniteFloat {
Negative,
Positive,
}
enum FiniteFloat { /* ... */ }
Но в этом уже нет такой серьёзной причины как в отделении от NaN
'а. Такое разделение можно использовать для более чёткого контроля типов данных на этапе компиляции.
Например, можно создать тип FloatFrom0ToInf
, который содержит все положительные числа и включая ноль. И затем сказать что fn div_one(down: FloatFrom0ToInf) -> FloatFrom0ToInf
. Ну и вообще рассчитать все операции друг из друга, и записать это на системах типов.
Либо же NotNanFloat
можно расписать так:
enum NotNanFloat {
PositiveFloat(PositiveFloat),
NegativeFloat(NegativeFloat),
}
enum PositiveFloat {
Infinity,
FinitePositiveFloat(FinitePositiveFloat),
}
enum FinitePositiveFloat { /* ... */ }
Здесь можно сделать метод:
fn mul(PositiveFiniteFloat, PositiveFiniteFloat) -> PositiveFloat;
Или надо отделить ноль от всего остального, и можно получить деление, которое не должно вызывать NaN
:
fn div(FiniteFloat, NonZeroFiniteFloat) -> NonZeroFiniteFloat;
А ещё можно иметь крутые сообщения об ошибках, когда ты пытаешься преобразовать более широкий тип к более маленькому:
let a: Float = -1.2;
let b: PositiveFiniteFloat = a.float_unwrap(); // "Can't convert negative float `-1.2` to positive number in line N"
Такие типы могли бы быть очень удобны во всяких библиотечных функциях. Например, функция расчёта площади треугольника по трём сторонам, которая принимает только конечные положительные числа, чтобы не валидировать их внутри:
fn triangle_area(
a: PositiveNonZeroFiniteFloat,
b: PositiveNonZeroFiniteFloat,
c: PositiveNonZeroFiniteFloat,
) -> Option<PositiveNonZeroFiniteFloat> { /* ... */ }
// В этой функции возвращается `Option<_>`, потому что в процессе вычислений может возникнуть бесконечность.
И таких способов расписать NotNanFloat
существует очень много. Для этого, бы, наверное, в Rust пришлось создать целую языковую конструкцию safe union
, которая позволяет переключаться между этими представлениями Float
, потому что они абсолютно эквивалентны.
#То же самое, но на типах
Или это разделение можно записывать по-другому:
struct Yes;
struct No;
trait IsFloatParameter {}
impl IsFloatParameter for Yes;
impl IsFloatParameter for No;
struct Float<
HasNan: IsFloatParameter,
HasNegativeInfinity: IsFloatParameter,
HasNegativeFrom1: IsFloatParameter,
HasNegative1: IsFloatParameter,
HasNegativeFrom0To1: IsFloatParameter,
HasNegative0: IsFloatParameter,
HasPositive0: IsFloatParameter,
HasPositiveFrom0To1: IsFloatParameter,
HasPositive1: IsFloatParameter,
HasPositiveFrom1: IsFloatParameter,
HasPositiveInfinity: IsFloatParameter,
>(f64);
type NotNanFloat = Float<
No,
Yes,
Yes, Yes, Yes, Yes,
Yes, Yes, Yes, Yes,
Yes,
>;
Но в таком случае теряется возможность матчиться по структуре данных, чтобы получить нужный промежуток. Не знаю что лучше.
Для подобного представления очень пригодятся умения программировать сложные вычисления на типах данных, чтобы автоматически получать все возможные преобразования для всех возможных комбинаций вещественных чисел.
Если же делать это вручную, то потребуется очень много кода. Вероятно, эту задачу было бы логичнее решать на зависимых типах? Или нужна новая, более сильная абстракция, чем ADT?
#Поддержка от компилятора
Ещё, если такая система будет существовать, например, на Rust, то она должна поддерживаться со стороны компилятора аналогично NonNull
, чтобы паттерн-матчинг по флоату раскрывался в максимально эффективные ассемблерные команды проверки, и чтобы хранение такого флоата в enum'ах PositiveFloat
, NotNanFloat
не тратило лишней памяти.
#Заключение
Наверное, поэтому даже в языках со статической типизацией, столько страданий приносят вещественные числа со своими бесконечностями и NaN
'ами? Может быть, с этими типами данных, станет немного проще жить? Не знаю. Это лишь идея, возникающая на почве статической типизации головного мозга.
Динамическая типизация в JS легко сводится к статической типизации, потому что с логической точки зрения никакие операции над int
не должны приводить к случайному возникновению string
, а в вещественных числах всё сложнее, там все значения между собой очень сильно связаны, и вот простое сложение двух конечных чисел может дать бесконечность, а отношение двух конечных чисел может дать NaN
.
Надеюсь эта идея вдохновила вас.
А если хочется NotNanFloat
иметь у себя в коде на Rust, то можно искать крейт по ключевым словам: ordered float
.