Плавающая точка == когда-либо в порядке?

Только сегодня я наткнулся на стороннее программное обеспечение, которое мы используем, и в его примере кода было что-то вроде этого:

// Defined in somewhere.h
static const double BAR = 3.14;

// Code elsewhere.cpp
void foo(double d)
{
    if (d == BAR)
        ...
}

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

Кроме того, как насчет вызова, как foo(BAR)? Будет ли это всегда сравниваться одинаково, поскольку они оба используют один и тот же static const BAR?

14 ответов

Решение

Есть два способа ответить на этот вопрос:

  1. Есть ли случаи, когда float == float дает правильный результат?
  2. Есть ли случаи, когда float == float приемлемое кодирование?

Ответ на (1): да, иногда. Но это будет хрупким, что приводит к ответу на (2): Нет. Не делай этого. Вы просите странных ошибок в будущем.

Что касается вызова формы foo(BAR): В этом конкретном случае сравнение вернет true, но когда вы пишете foo Вы не знаете (и не должны зависеть) как это называется. Например, позвонив foo(BAR) будет хорошо но foo(BAR * 2.0 / 2.0) (или даже может быть foo(BAR * 1.0) в зависимости от того, насколько компилятор что-то оптимизирует) сломается. Вы не должны полагаться на то, что вызывающая сторона не выполняет арифметику!

Короче говоря, хотя a == b будет работать в некоторых случаях, вы действительно не должны полагаться на это. Даже если вы можете гарантировать семантику вызовов сегодня, возможно, вы не сможете гарантировать их на следующей неделе, так что избавьте себя от боли и не используйте ==,

На мой взгляд, float == float никогда не * хорошо, потому что это практически невозможно

* Для небольших значений никогда.

Да, вам гарантировано, что целые числа, включая 0.0, сравниваются с ==

Конечно, вы должны быть немного осторожны с тем, как вы получили целое число в первую очередь, назначение безопасно, но результат любого вычисления сомнителен

PS Есть набор действительных чисел, которые имеют идеальное воспроизведение в виде числа с плавающей точкой (например, 1/2, 1/4 1/8 и т. д.), но вы, вероятно, заранее не знаете, что у вас есть одно из них.

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

float a=1.0;
float b=1.0;
a==b  // true

Но вы должны быть осторожны, как вы получаете целые числа

float a=1.0/3.0;
a*3.0 == 1.0  // not true !!

Другие ответы довольно хорошо объясняют, почему использование == для чисел с плавающей точкой это опасно. Я только что нашел один пример, который достаточно хорошо иллюстрирует эти опасности.

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

#include <stdio.h>

void test(double x, double y)
{
  const double y2 = x + 1.0;
  if (y != y2)
    printf("error\n");
}

void main()
{
  const double x = .012;
  const double y = x + 1.0;

  test(x, y);
}

Программа по сути просто рассчитывает

x = 0.012 + 1.0;
y = 0.012 + 1.0;

(распространяется только на две функции и с промежуточными переменными), но сравнение все равно может дать ложь!

Причина в том, что на платформе x86 программы обычно используют FPU x87 для вычислений с плавающей запятой. Внутренние вычисления x87 выполняются с большей точностью, чем обычные double, так double значения должны быть округлены, когда они хранятся в памяти. Это означает, что круговая передача x87 -> RAM -> x87 теряет точность, и, следовательно, результаты вычислений различаются в зависимости от того, были ли промежуточные результаты переданы через RAM или все они остались в регистрах FPU. Это, конечно, решение компилятора, поэтому ошибка проявляется только для определенных компиляторов и настроек оптимизации:-(.

Подробнее см. Ошибку GCC: http://gcc.gnu.org/bugzilla/show_bug.cgi?id=323

Скорее страшно...

Дополнительное примечание:

Ошибки такого рода, как правило, будут довольно сложными для отладки, потому что разные значения становятся одинаковыми после попадания в ОЗУ.

Так что если, например, вы расширяете вышеупомянутую программу, чтобы фактически распечатать y а также y2 сразу после их сравнения вы получите точно такое же значение. Чтобы напечатать значение, оно должно быть загружено в ОЗУ для передачи в какую-либо функцию печати, например printfи это сделает разницу исчезнет...

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

#include <stdio.h>
#include <math.h>

/* let's try to numerically solve a simple equation F(x)=0 */
double F(double x) {
    return 2*cos(x) - pow(1.2, x);
}

/* I'll use a well-known, simple&slow but extremely smart method to do this */
double bisection(double range_start, double range_end) {
    double a = range_start;
    double d = range_end - range_start;
    int counter = 0;
    while(a != a+d) // <-- WHOA!!
    {
        d /= 2.0;
        if(F(a)*F(a+d) > 0) /* test for same sign */
            a = a+d;

        ++counter;
    }
    printf("%d iterations done\n", counter);
    return a;
}

int main() {
    /* we must be sure that the root can be found in [0.0, 2.0] */
    printf("F(0.0)=%.17f, F(2.0)=%.17f\n", F(0.0), F(2.0));

    double x = bisection(0.0, 2.0);

    printf("the root is near %.17f, F(%.17f)=%.17f\n", x, x, F(x));
}

Я бы не стал объяснять используемый метод деления пополам, но остановлюсь на условии остановки. Это имеет точно обсуждаемую форму: (a == a+d) где обе стороны плавают: a наше текущее приближение корня уравнения, и d наша текущая точность. Учитывая предварительное условие алгоритма - что должен быть корень между range_start а также range_end - мы гарантируем на каждой итерации, что корень остается между a а также a+d в то время как d на каждом шагу уменьшается вдвое, сокращая границы.

И затем, после ряда итераций, d становится настолько маленьким, что во время сложения с a оно округляется до нуля! То есть, a+d оказывается ближе к a затем на любой другой поплавок; и поэтому FPU округляет его до ближайшего значения: до a сам. Это может быть легко проиллюстрировано расчетом на гипотетической вычислительной машине; пусть у него будет 4-значная десятичная мантисса и большой диапазон экспонент. Тогда какой результат должна дать машина? 2.131e+02 + 7.000e-3? Точный ответ 213.107, но наша машина не может представлять такое число; это должно округлить это. А также 213.107 гораздо ближе к 213.1 чем 213.2 - так получается округленный результат 2.131e+02 - небольшое слагаемое исчезло, округленное до нуля. Точно то же самое гарантированно произойдет на некоторой итерации нашего алгоритма - и в этот момент мы больше не можем продолжать. Мы нашли корень максимально возможной точности.

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


Обновить

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

Возможно, вы уже знаете, что в исчислении нет понятия "маленькое число": для любого действительного числа вы можете легко найти бесконечное множество даже меньших. Проблема в том, что одним из этих "еще меньших" может быть то, что мы на самом деле ищем; это может быть корнем нашего уравнения. Еще хуже, для разных уравнений могут быть разные корни (например, 2.51e-8 а также 1.38e-8), оба из которых будут аппроксимированы одним и тем же числом, если наше условие остановки будет выглядеть d < 1e-6, Какой бы "маленький номер" вы ни выбрали, множество корней, которые были бы найдены правильно с максимальной точностью при a == a+d условие остановки будет испорчено слишком большим"эпсилоном".

Однако верно, что в числах с плавающей запятой показатель степени имеет ограниченный диапазон, так что вы можете найти наименьшее ненулевое положительное число FP (например, 1e-45 Денорм для IEEE 754 FP одинарной точности). Но это бесполезно! while (d < 1e-45) {...} будет цикл навсегда, предполагая одинарную точность (положительный ненулевой) d,

Оставляя в стороне эти патологические крайние случаи, любой выбор "малого числа" в d < eps условие остановки будет слишком маленьким для многих уравнений. В тех уравнениях, где корень имеет достаточно высокий показатель степени, результат вычитания двух мантисс, отличающихся только наименьшей значащей цифрой, легко превысит наш "эпсилон". Например, с 6-значными мантиссами 7.00023e+8 - 7.00022e+8 = 0.00001e+8 = 1.00000e+3 = 1000Это означает, что наименьшая возможная разница между числами с показателем степени +8 и 5-значной мантиссой равна... 1000! Который никогда не будет вписываться, скажем, 1e-4, Для этих чисел с (относительно) высоким показателем у нас просто недостаточно точности, чтобы когда-либо увидеть разницу 1e-4,

Моя реализация выше приняла эту последнюю проблему во внимание, и вы можете видеть, что d делится пополам на каждый шаг, вместо того, чтобы быть пересчитанным как разница (возможно, огромная в показателе степени) a а также b, Так что, если мы изменим условие остановки на d < eps, алгоритм не будет застрять в бесконечном цикле с огромными корнями (b-a) < eps), но все равно будет выполнять ненужные итерации во время сжатия d ниже точности a,

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

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

Но короткий ответ: "Нет, не используйте ==."

По иронии судьбы, формат с плавающей запятой работает "идеально", то есть с точной точностью, при работе с интегральными значениями в пределах диапазона формата. Это означает, что если вы придерживаетесь двойных значений, вы получите совершенно хорошие целые числа с чуть более 50 битами, что даст вам +- 4 500 000 000 000 000 или 4,5 квадриллиона.

Фактически, именно так JavaScript работает внутренне, и именно поэтому JavaScript может делать такие вещи, как + а также - на действительно большие цифры, но может только << а также >> на 32-битных.

Строго говоря, вы можете точно сравнивать суммы и произведения чисел с точными представлениями. Это будут целые числа плюс дроби, состоящие из 1/2n членов. Таким образом, цикл с приращением на n + 0,25, n + 0,50 или n + 0,75 вполне подойдет, но не любые другие 96 десятичных дробей с 2 ​​цифрами.

Таким образом, ответ таков: хотя точное равенство теоретически может иметь смысл в узких случаях, его лучше избегать.

Единственный случай, когда я использую == (или же !=) для поплавков заключается в следующем:

if (x != x)
{
    // Here x is guaranteed to be Not a Number
}

и я должен признать, что я виновен в использовании Not A Number в качестве магической константы с плавающей запятой (используя numeric_limits<double>::quiet_NaN() в C++).

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

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

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

По моему мнению, сравнение на равенство (или некоторую эквивалентность) является требованием в большинстве ситуаций: стандартные контейнеры или алгоритмы C++ с функтором сравнения подразумеваемого равенства, например, например, std::unordered_set, требуют, чтобы этот компаратор был отношением эквивалентности (см. C++ именованные требования: UnorderedAssociativeContainer).

К сожалению, сравнивая с эпсилоном как в abs(a - b) < epsilon не дает отношения эквивалентности, так как теряет транзитивность. Скорее всего, это неопределенное поведение, в частности, два "почти равных" числа с плавающей точкой могут давать разные хэши; это может перевести unordered_set в недопустимое состояние. Лично я большую часть времени использовал бы == для чисел с плавающей запятой, если только какие-либо вычисления FPU не были бы задействованы для каких-либо операндов. С контейнерами и контейнерными алгоритмами, где задействованы только чтение / запись, == (или любое отношение эквивалентности) является самым безопасным.

abs(a - b) < epsilon более или менее критерии сходимости, аналогичные пределу. Я считаю это соотношение полезным, если мне нужно убедиться, что между двумя вычислениями сохраняется математическая идентичность (например, PV = nRT или расстояние = время * скорость).

Короче говоря, используйте == тогда и только тогда, когда вычисление с плавающей запятой не происходит; никогда не использовать abs(a-b) < e как предикат равенства;

Допустим, у вас есть функция, которая масштабирует массив чисел с постоянным коэффициентом:

void scale(float factor, float *vector, int extent) {
   int i;
   for (i = 0; i < extent; ++i) {
      vector[i] *= factor;
   }
}

Я предполагаю, что ваша реализация с плавающей запятой может точно представлять 1.0 и 0.0, а 0.0 представлен всеми 0 битами.

Если factor равен точно 1.0, тогда эта функция не работает, и вы можете вернуться, не выполняя никакой работы. Если factor равен точно 0,0, тогда это может быть реализовано с помощью вызова memset, что, вероятно, будет быстрее, чем выполнение умножения с плавающей запятой по отдельности.

Эталонная реализация функций BLAS в netlib широко использует такие методы.

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

Пример:

float someFunction (float argument)
{
  // I really want bit-exact comparison here!
  if (argument != lastargument)
  {
    lastargument = argument;
    cachedValue = very_expensive_calculation (argument);
  }

  return cachedValue;
}

Да. 1/x будет действительным, если x==0, Вам не нужен неточный тест здесь. 1/0.00000001 отлично в порядке. Я не могу вспомнить ни одного другого случая - ты даже не можешь проверить tan(x) за x==PI/2

Я бы сказал, что сравнение поплавков на равенство будет в порядке, если ложноотрицательный ответ приемлем.

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

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

Чтобы сделать "правильное" сравнение с плавающей запятой, мне нужно было бы найти некоторый диапазон, в котором можно считать точки одинаковыми. Так как пользователь может увеличивать до бесконечности и работать в этом диапазоне, и так как я не мог заставить кого-либо зафиксировать какой-либо диапазон, мы просто используем '==', чтобы увидеть, совпадают ли точки. Иногда возникает проблема, когда точки, которые должны быть одинаковыми, отклоняются на.000000000001 или что-то (особенно около 0,0), но обычно это работает просто отлично. Должно быть трудно объединить точки без включенной оснастки... или, по крайней мере, так работала оригинальная версия.

Иногда выкидывает группу тестирования, но это их проблема: p

Так или иначе, есть пример возможного разумного использования "==". Следует отметить, что решение заключается не столько в технической точности, сколько в пожеланиях клиента (или их отсутствии) и удобстве. Это не то, что должно быть так точно. Так что, если две точки не сливаются, когда вы ожидаете их? Это не конец света и не повлияет на "расчеты".

Другие вопросы по тегам