Равномерное равенство

Общеизвестно, что при сравнении значений с плавающей запятой нужно быть осторожным. Обычно вместо использования == мы используем тестирование на основе равенства epsilon или 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

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

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

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


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

Ответы

Ответ 1

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

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

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);
}

См. Также С плавающей точкой == когда-либо ОК? и является ли плавающей точкой == когда-либо ОК? ,

Ответ 2

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

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

Ответ 3

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

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

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


ЮРИДИЧЕСКАЯ

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

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

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

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

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

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

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

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

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

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

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

(Я думаю, это примечание, которое Maciej Piechotka означало в комментариях - нумерация, похоже, изменилась в версии стандарта, который он использовал).

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

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

Ответ 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, но это то, с чем вы можете столкнуться большую часть времени.

Ответ 5

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

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

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

Ответ 6

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

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

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

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