Равенство с плавающей точкой

Общеизвестно, что нужно быть осторожным при сравнении значений с плавающей запятой. Обычно вместо использования ==Мы используем некоторые тесты на равенство на основе эпсилон или ULP.

Однако, интересно, есть ли случаи при использовании == отлично в порядке?

Посмотрите на этот простой фрагмент, какие случаи гарантированно будут успешными?

void fn(float a, float b) {
float l1 = a/b;
float l2 = a/b;

if (l1==l1) { }        // case a)
if (l1==l2) { }        // case b)
if (l1==a/b) { }       // case c)
if (l1==5.0f/3.0f) { } // case d)
}

int main() {
fn(5.0f, 3.0f);
}

Примечание: я проверил этот а также этот, но они не покрывают (все) мои дела.

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

  • что говорит стандарт C ++
  • что произойдет, если реализация C ++ будет следовать IEEE-754

Это единственное соответствующее утверждение, которое я нашел в текущий проект стандарта:

Представление значений типов с плавающей запятой определяется реализацией. [Примечание: этот документ не предъявляет никаких требований к точности операций с плавающей запятой; см. также [support.limits]. — конец примечания]

Значит ли это, что даже «case a)» определяется реализацией? Я имею в виду, l1==l1 это определенно операция с плавающей точкой. Итак, если реализация «неточная», то может l1==l1 быть ложным?


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

46

Решение

Однако, мне интересно, есть ли случаи, когда использование == прекрасно?

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

void setRange(float min, float max)
{
if(min == m_fMin && max == m_fMax)
return;

m_fMin = min;
m_fMax = max;

// Do something with min and/or max
emit rangeChanged(min, max);
}

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

15

Другие решения

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

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

6

Только a) и b) гарантированно преуспеют в любой вменяемой реализации (подробности см. Ниже), так как они сравнивают два значения, которые были получены таким же образом и округлены до float точность. Следовательно, оба сравниваемых значения гарантированно будут идентичны последнему биту.

Случаи c) и d) могут потерпеть неудачу, потому что вычисление и последующее сравнение могут быть выполнены с большей точностью, чем float, Различное округление double должно быть достаточно, чтобы провалить тест.

Обратите внимание, что случаи a) и b) могут все же потерпеть неудачу, если задействованы бесконечности или NAN.


ЮРИДИЧЕСКАЯ

Используя рабочий проект стандарта N3242 C ++ 11, я обнаружил следующее:

В тексте, описывающем выражение присваивания, прямо указано, что происходит преобразование типов, [expr.ass] 3:

Если левый операнд не относится к типу класса, выражение неявно преобразуется (пункт 4) в cv-неквалифицированный тип левого операнда.

Раздел 4 относится к стандартным преобразованиям [conv], которые содержат следующее для преобразований с плавающей запятой, [conv.double] 1:

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

(Акцент мой.)

Таким образом, у нас есть гарантия того, что результат преобразования фактически определен, если только мы не имеем дело со значениями за пределами представимого диапазона (например, float a = 1e300, который является UB).

Когда люди думают о том, что «внутреннее представление с плавающей точкой может быть более точным, чем видимое в коде», они думают о следующем предложении в стандарте, [expr] 11:

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

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

Операторы приведения и присваивания по-прежнему должны выполнять свои конкретные преобразования, как описано в 5.4, 5.2.9 и 5.17.

(Думаю, это сноска, которую Мачей Пехотка имел в виду в комментариях — нумерация, похоже, изменилась в версии стандарта, которую он использовал.)

Итак, когда я говорю float a = some_double_expression;У меня есть гарантия, что результат выражения на самом деле округляется, чтобы быть представленным float (вызывая UB, только если значение выходит за пределы), и a будет ссылаться на это округленное значение впоследствии.

Реализация может действительно указывать, что результат округления является случайным, и, таким образом, разбивать случаи a) и b). Тем не менее, разумные реализации этого не сделают.

4

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

Поэтому, если вы точно знаете, что не делаете ничего, что могло бы привести к непредставлению чего-либо, у вас все в порядке. Например

float a = 1.0f;
float b = 1.0f;
float c = 2.0f;
assert(a + b == c); // you can safely expect this to succeed

Ситуация действительно ухудшается только в том случае, если у вас есть вычисления с не совсем точными результатами (или с точными операциями), и вы меняете порядок операций.

Обратите внимание, что сам стандарт C ++ не гарантирует семантику IEEE 754, но с этим вы можете ожидать большую часть времени.

1

Случай (а) терпит неудачу, если a == b == 0.0, В этом случае операция дает NaN, и по определению (IEEE, а не C) NaN ≠ NaN.

Случаи (b) и (c) могут давать сбой при параллельном вычислении, когда режимы с плавающей запятой (или другие режимы вычислений) изменяются в середине выполнения этого потока. Видел это на практике, к сожалению.

Случай (d) может отличаться, потому что компилятор (на некоторой машине) может выбрать постоянное свертывание вычисления 5.0f/3.0f и заменить его постоянным результатом (с неопределенной точностью), тогда как a/b должен быть вычислен во время выполнения на целевой машине (которая может радикально отличаться). Фактически промежуточные вычисления могут выполняться с произвольной точностью. Я видел различия в старых архитектурах Intel, когда промежуточные вычисления выполнялись в 80-битном формате с плавающей запятой, формате, который язык даже не поддерживал напрямую.

1

По моему скромному мнению, вы не должны полагаться на == оператор, потому что у него много угловых случаев. Самая большая проблема — округление и повышенная точность. В случае x86, операции с плавающей точкой могут выполняться с большей точностью, чем вы можете хранить в переменных (если вы используете сопроцессоры, IIRC SSE операции используют ту же точность, что и хранилище).

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

Чтобы иметь 100% уверенность, вам нужно взглянуть на сборку и посмотреть, какие операции были выполнены ранее для обоих значений. Даже порядок может изменить результат в нетривиальных случаях.

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

0
По вопросам рекламы [email protected]