Общеизвестно, что нужно быть осторожным при сравнении значений с плавающей запятой. Обычно вместо использования ==
Мы используем некоторые тесты на равенство на основе эпсилон или 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: Кажется, мне нужно добавить некоторую положительную информацию, поэтому ответы могут быть полезны на практике: я хотел бы знать:
Это единственное соответствующее утверждение, которое я нашел в текущий проект стандарта:
Представление значений типов с плавающей запятой определяется реализацией. [Примечание: этот документ не предъявляет никаких требований к точности операций с плавающей запятой; см. также [support.limits]. — конец примечания]
Значит ли это, что даже «case a)» определяется реализацией? Я имею в виду, l1==l1
это определенно операция с плавающей точкой. Итак, если реализация «неточная», то может l1==l1
быть ложным?
Я думаю, что этот вопрос не является дубликатом Плавающая точка == когда-либо в порядке?. Этот вопрос не касается ни одного из случаев, которые я задаю. Та же тема, другой вопрос. Я хотел бы получить ответы конкретно для случая а) -г), для которого я не могу найти ответы в дублированном вопросе.
Однако, мне интересно, есть ли случаи, когда использование == прекрасно?
Конечно, есть. Одной категорией примеров являются способы использования, которые не требуют вычислений, например сеттеры, которые должны выполняться только при изменениях:
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);
}
Смотрите также Плавающая точка == когда-либо в порядке? а также Плавающая точка == когда-либо в порядке?.
Придуманные дела могут «работать». Практические случаи могут все еще потерпеть неудачу. Еще одна проблема заключается в том, что зачастую оптимизация приводит к небольшим изменениям в способе расчета, поэтому символически результаты должны быть одинаковыми, но численно они различаются. Приведенный выше пример теоретически может потерпеть неудачу в таком случае. Некоторые компиляторы предлагают возможность получения более согласованных результатов при снижении производительности. Я бы посоветовал «всегда» избегать равенства чисел с плавающей точкой.
Равенство физических измерений, а также числа, хранящиеся в цифровом виде, часто бессмысленно. Так что, если вы сравниваете, если в вашем коде числа с плавающей точкой равны, вы, вероятно, делаете что-то не так. Вы обычно хотите больше или меньше или в пределах допуска. Часто код можно переписать, чтобы избежать подобных проблем.
Только 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). Тем не менее, разумные реализации этого не сделают.
Предполагая семантику 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, но с этим вы можете ожидать большую часть времени.
Случай (а) терпит неудачу, если a == b == 0.0
, В этом случае операция дает NaN, и по определению (IEEE, а не C) NaN ≠ NaN.
Случаи (b) и (c) могут давать сбой при параллельном вычислении, когда режимы с плавающей запятой (или другие режимы вычислений) изменяются в середине выполнения этого потока. Видел это на практике, к сожалению.
Случай (d) может отличаться, потому что компилятор (на некоторой машине) может выбрать постоянное свертывание вычисления 5.0f/3.0f
и заменить его постоянным результатом (с неопределенной точностью), тогда как a/b
должен быть вычислен во время выполнения на целевой машине (которая может радикально отличаться). Фактически промежуточные вычисления могут выполняться с произвольной точностью. Я видел различия в старых архитектурах Intel, когда промежуточные вычисления выполнялись в 80-битном формате с плавающей запятой, формате, который язык даже не поддерживал напрямую.
По моему скромному мнению, вы не должны полагаться на ==
оператор, потому что у него много угловых случаев. Самая большая проблема — округление и повышенная точность. В случае x86, операции с плавающей точкой могут выполняться с большей точностью, чем вы можете хранить в переменных (если вы используете сопроцессоры, IIRC SSE операции используют ту же точность, что и хранилище).
Обычно это хорошо, но это вызывает такие проблемы, как:
1./2 != 1./2
потому что одно значение является переменной формы, а второе — из регистра с плавающей запятой. В простейших случаях это будет работать, но если вы добавите другие операции с плавающей запятой, компилятор может решить разделить некоторые переменные в стеке, изменив их значения, тем самым изменив результат сравнения.
Чтобы иметь 100% уверенность, вам нужно взглянуть на сборку и посмотреть, какие операции были выполнены ранее для обоих значений. Даже порядок может изменить результат в нетривиальных случаях.
В целом, какой смысл использовать ==
? Вы должны использовать алгоритмы, которые являются стабильными. Это означает, что они работают, даже если значения не равны, но они все равно дают те же результаты. Единственное место, где я знаю, где ==
может быть полезно сериализацию / десериализацию, когда вы точно знаете, какой результат вы хотите получить, и вы можете изменить сериализацию для архивирования вашей цели.