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

 double a = 1.;
 double b = 9007199254740992.; // 2 ^ 53
 double c = -9007199254740992.;
 double d = (a + b) + c;
 double e = a + (b + c);
 
 assert(d != e);

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

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

 double a = 0.;
 double b = -0.;
 assert(a == b);
 // 0. != -0.
 auto a_string = std::to_string(a);
 auto b_string = std::to_string(b);
 assert(a_string != b_string);

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

К сожалению, эта двойственность приносит больше хаоса, чем комфорта. Рассмотрите возможность независимой проверки и проверки. Допустим, вам нужно сделать это для какой-то функции, которая возвращает «-0.0», в то время как в плане проверки ясно сказано, что это должно быть «0.0». Считаете ли вы это несоответствием или нет, зависит только от семантики функции.

Третий печальный факт о числах с плавающей запятой заключается в том, что они не являются надмножеством целых чисел.

 uint64_t a = numeric_limits<uint64_t>::max();
 uint64_t b = numeric_limits<uint64_t>::max() — 1;
 double double_a = static_cast<double>(a);
 double double_b = static_cast<double>(b);
 assert(sizeof(a) == sizeof(double_a));
 assert(a == b + 1);
 assert(double_a == double_b); // lost one

Конечно, нет. Вы не можете просто сопоставить два разных набора с одним и тем же набором 2⁶⁴ битовых комбинаций и ожидать, что один будет надмножеством другого. К сожалению, это означает, что вы не можете считать их полностью взаимозаменяемыми, поэтому вы не можете строить над ними надежные абстракции. Это работает, или целые числа не обязательно будут работать для чисел с плавающей запятой и наоборот.

Мало того, что они не взаимозаменяемы, так еще и их переделка на современном железе достаточно затратна. В семействе Intel один должен обрабатываться в регистрах ЦП, а другой - в регистрах сопроцессора, и эти два коммутируются только через ОЗУ. Это может быть не всегда верно на уровне микрокода, но на уровне инструкций это так. Поэтому, когда вы видите какую-то шаблонную функцию, которая принимает либо число с плавающей запятой, либо целое число, обычно лучше разделить ее на две части, тщательно следя за присущими им неэффективными типами.

Почему же я называю эти факты печальными или даже удручающими? Только потому, что мы ничего не можем с этим поделать. Биты конечны, а числа, действительные или рациональные, или целые, — нет. Невозможно сопоставить бесконечные числа с конечными битовыми комбинациями. У нас никогда не будет реальных чисел на компьютере. Это то, что меня огорчает.

Кроме того, числа с плавающей запятой в порядке. Они быстрые, компактные и детерминированные как по времени, так и по точности. Они также покрыты стандартом IEEE, так что никакой тайны в них нет. Обратите внимание, что хотя мы должны написать `int64_t` в C++, чтобы указать длину типа, мы не делаем этого с `double`. Длина `double` фактически указана в стандарте, в то время как длина `int` зависит от машины.

Мораль сказки: знай свои числа, будь готов к ошибке, остерегайся абстракций, - и не грусти.