5.6 – Операторы отношения и сравнение чисел с плавающей запятой

Добавлено 8 мая 2021 в 14:16

Операторы отношения – это операторы, позволяющие сравнивать два значения. Существует 6 операторов отношения:

Операторы отношения
ОператорОбозначениеПример использованияОперация
Больше>x > ytrue, если x больше, чем y; в противном случае – false
Меньше<x < ytrue, если x меньше, чем y; в противном случае – false
Больше или равно>=x >= ytrue, если x больше или равно y; в противном случае – false
Меньше или равно<=x <= ytrue, если x меньше или равно y; в противном случае – false
Равно==x == ytrue, если x равно y; в противном случае – false
Не равно!=x != ytrue, если x не равно y; в противном случае – false

Вы уже видели, как работает большинство из них, и они интуитивно понятны. Каждый из этих операторов возвращает логическое значение true (1) или false (0).

Вот пример кода, использующего эти операторы с целочисленными значениями:

#include <iostream>
 
int main()
{
    std::cout << "Enter an integer: ";
    int x{};
    std::cin >> x;
 
    std::cout << "Enter another integer: ";
    int y{};
    std::cin >> y;
 
    if (x == y)
        std::cout << x << " equals " << y << '\n';
    if (x != y)
        std::cout << x << " does not equal " << y << '\n';
    if (x > y)
        std::cout << x << " is greater than " << y << '\n';
    if (x < y)
        std::cout << x << " is less than " << y << '\n';
    if (x >= y)
        std::cout << x << " is greater than or equal to " << y << '\n';
    if (x <= y)
        std::cout << x << " is less than or equal to " << y << '\n';
 
    return 0;
}

И результаты пробного запуска:

Enter an integer: 4
Enter another integer: 5
4 does not equal 5
4 is less than 5
4 is less than or equal to 5

При сравнении значений целочисленных типов эти операторы чрезвычайно просто использовать.

Логические условные значения

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

Многие начинающие программисты могут писать подобные инструкции:

if (b1 == true) ...

Это избыточно, поскольку == true фактически не добавляет никакого значения условию. Вместо этого мы должны написать:

if (b1) ...

Аналогично следующее:

if (b1 == false) ...

лучше записать как:

if (!b1) ...

Лучшая практика


Не добавляйте в условия ненужные == или !=. Это затрудняет чтение, не предлагая никакой дополнительной ценности.

Сравнение значений с плавающей запятой

Рассмотрим следующую программу:

#include <iostream>
 
int main()
{
    double d1{ 100.0 - 99.99 }; // должно быть равно 0.01
    double d2{ 10.0 - 9.99 };   // должно быть равно 0.01
 
    if (d1 == d2)
        std::cout << "d1 == d2" << '\n';
    else if (d1 > d2)
        std::cout << "d1 > d2" << '\n';
    else if (d1 < d2)
        std::cout << "d1 < d2" << '\n';
    
    return 0;
}

Обе переменные d1 и d2 должны иметь значение 0,01. Но эта программа выдает неожиданный результат:

d1 > d2

Если вы проверите значения d1 и d2 в отладчике, вы, вероятно, увидите, что d1 = 0,0100000000000005116, а d2 = 0,0099999999999997868. Оба числа близки к 0,01, но d1 больше, а d2 меньше.

Если требуется высокий уровень точности, сравнение значений с плавающей запятой с использованием любого из операторов отношения может быть опасным. Это связано с тем, что значения с плавающей запятой неточны, а небольшие ошибки округления в операндах с плавающей запятой могут привести к неожиданным результатам. Если вам нужно напомнить, то мы обсуждали ошибки округления в уроке «4.8 – Числовые типы с плавающей точкой».

Когда операторы «меньше» и «больше» (<, <=, > и >=) используются со значениями с плавающей запятой, они обычно дают правильный ответ (потенциальный сбой возникает, только если операнды почти идентичны). Из-за этого использование этих операторов с операндами с плавающей запятой может быть приемлемым, если последствия получения неправильного ответа при схожих операндах незначительны.

Например, рассмотрим игру (такую ​​как Space Invaders), в которой вы хотите определить, пересекаются ли два движущихся объекта (например, ракета и инопланетянин). Если объекты всё еще находятся далеко друг от друга, эти операторы вернут правильный ответ. Если два объекта расположены очень близко друг к другу, вы можете получить любой ответ. В таких случаях неправильный ответ, вероятно, даже не будет замечен (это будет просто выглядеть как промах или попадание), и игра продолжится.

Равенство значений с плавающей запятой

Операторы равенства (== и !=) намного сложнее. Рассмотрим оператор ==, который возвращает истину, только если его операнды в точности равны. Поскольку даже самая маленькая ошибка округления приведет к тому, что два числа с плавающей запятой не будут равны, operator== имеет высокий риск возврата false, когда можно было бы ожидать true. У оператора != такая же проблема.

По этой причине следует избегать использования этих операторов с операндами с плавающей запятой.

Предупреждение


Избегайте использования операторов operator== и operator!= с операндами с плавающей запятой.

Итак, как мы можем корректно сравнить два операнда с плавающей запятой, чтобы увидеть, равны ли они?

Самый распространенный метод обеспечения равенства с плавающей запятой включает использование функции, которая проверяет, почти ли равны два числа. Если они «достаточно близки», то мы называем их равными. Значение, используемое для обозначения «достаточно близко», традиционно называется эпсилон. Эпсилон обычно определяется как небольшое положительное число (например, 0,00000001, иногда пишется 1e-8).

Начинающие разработчики часто пытаются написать свою собственную функцию «достаточно близки» следующим образом:

#include <cmath> // для std::abs()
 
// эпсилон - абсолютное значение
bool isAlmostEqual(double a, double b, double epsilon)
{
    // если расстояние между a и b меньше эпсилон, тогда a и b "достаточно близки"
    return std::abs(a - b) <= epsilon;
}

std::abs() – это функция в заголовке <cmath>, которая возвращает абсолютное значение (модуль) своего аргумента. Итак, std::abs(a - b) <= epsilon проверяет, меньше ли расстояние между a и b, чем любое значение epsilon, означающее «достаточно близко», которое было передано в функцию. Если a и b достаточно близки, функция возвращает true, чтобы указать, что они равны. В противном случае возвращается false.

Хотя эта функция может работать, но она не очень хороша. Эпсилон 0.00001 подходит для входных значений около 1.0, но слишком велико для входных значений около 0.0000001 и слишком мало для входных значений, таких как 10000. Это означает, что каждый раз, когда мы вызываем эту функцию, мы должны выбирать эпсилон, подходящий для наших входных данных. Если мы знаем, что нам придется масштабировать эпсилон пропорционально нашим входным данным, мы могли бы также изменить функцию, чтобы она делала это за нас.

Дональд Кнут, предложил следующий метод в своей книге «Искусство программирования, том 2: Получисленные алгоритмы»:

#include <cmath>     // std::abs
#include <algorithm> // std::max
 
// возвращаем истину, если разница между a и b находится в пределах 
// эпсилон-процента от большего из a и b
bool approximatelyEqual(double a, double b, double epsilon)
{
    return (std::abs(a - b) <= (std::max(std::abs(a), std::abs(b)) * epsilon));
}

В этом случае, вместо абсолютного значения, эпсилон теперь зависит от величины a или b.

Давайте рассмотрим подробнее, как работает эта выглядящая безумно функция. Левая часть оператора <= (т.е. std::abs(a - b)) сообщает нам расстояние между a и b как положительное число.

В правой части оператора <= нам нужно вычислить наибольшее значение «достаточно близко», которое мы готовы принять. Для этого алгоритм выбирает большее из a и b (в качестве приблизительного показателя общей величины чисел), а затем умножает его на эпсилон. В этой функции эпсилон представляет собой процент. Например, если мы хотим сказать «достаточно близко» означает, что a и b находятся в пределах 1% от большего из a и b, мы передаем эпсилон 0.01 (1% = 1/100 = 0,01). Значение эпсилон может быть изменено на любое, наиболее подходящее для обстоятельств (например, эпсилон 0.002 означает в пределах 0,2%).

Чтобы выполнить сравнение на неравенство (!=) вместо равенства, просто вызовите эту функцию и используйте оператор логического НЕ (!), чтобы инвертировать результат:

if (!approximatelyEqual(a, b, 0.001))
    std::cout << a << " is not equal to " << b << '\n';

Обратите внимание, что хотя функция approximatelyEqual() в большинстве случаев будет работать, она не идеальна, особенно когда числа близки к нулю:

#include <algorithm>
#include <iostream>
#include <cmath>
 
// возвращаем истину, если разница между a и b находится
// в пределах эпсилон-процента от большего из a и b
bool approximatelyEqual(double a, double b, double epsilon)
{
	return (std::abs(a - b) <= (std::max(std::abs(a), std::abs(b)) * epsilon));
}
 
int main()
{
	// a действительно близко к 1.0, но имеет ошибки округления, поэтому оно немного меньше 1.0
	double a{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 };
 
	// сначала сравним "почти 1.0" с 1.0.
	std::cout << approximatelyEqual(a, 1.0, 1e-8) << '\n';
 
	// а теперь давайте сравним a-1.0 (почти 0.0) с 0.0
	std::cout << approximatelyEqual(a-1.0, 0.0, 1e-8) << '\n';
}

Возможно, это будет сюрпризом, но программа напечатает следующее:

1
0

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

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

// возвращаем true, если разница между a и b меньше, чем absEpsilon, 
// или в пределах процента relEpsilon от большего значения a и b
bool approximatelyEqualAbsRel(double a, double b, double absEpsilon, double relEpsilon)
{
    // Проверяем, действительно ли числа близки - необходимо при сравнении чисел, близких к нулю.
    double diff{ std::abs(a - b) };
    if (diff <= absEpsilon)
        return true;
 
    // В противном случае возвращаемся к алгоритму Кнута
    return (diff <= (std::max(std::abs(a), std::abs(b)) * relEpsilon));
}

В этом алгоритме мы сначала проверяем, близки ли a и b друг к другу в абсолютном выражении, что обрабатывает случай, когда a и b оба близки к нулю. Параметр absEpsilon должен быть установлен на очень маленькое значение (например, 1e-12). Если это не удается, мы возвращаемся к алгоритму Кнута, используя относительный эпсилон.

Вот наш предыдущий код, проверяющий оба алгоритма:

#include <algorithm>
#include <iostream>
#include <cmath>
 
// возвращаем true, если разница между a и b находится
// в пределах эпсилон-процента от большего из a и b
bool approximatelyEqual(double a, double b, double epsilon)
{
	return (std::abs(a - b) <= (std::max(std::abs(a), std::abs(b)) * epsilon));
}
 
bool approximatelyEqualAbsRel(double a, double b, double absEpsilon, double relEpsilon)
{
    // Проверяем, действительно ли числа близки - необходимо при сравнении чисел, близких к нулю.
    double diff{ std::abs(a - b) };
    if (diff <= absEpsilon)
        return true;
 
    // В противном случае возвращаемся к алгоритму Кнута
    return (diff <= (std::max(std::abs(a), std::abs(b)) * relEpsilon));
}
 
int main()
{
    // a действительно близко к 1.0, но имеет ошибки округления
    double a{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 };
 
    // сравниваем "почти 1.0" с 1.0
    std::cout << approximatelyEqual(a, 1.0, 1e-8) << '\n'; 

    // сравниваем "почти 0.0" с 0.0
    std::cout << approximatelyEqual(a-1.0, 0.0, 1e-8) << '\n'; 

    // сравниваем "почти 0.0" с 0.0
    std::cout << approximatelyEqualAbsRel(a-1.0, 0.0, 1e-12, 1e-8) << '\n'; 
}
1
0
1

Вы можете видеть, что при правильно подобранном absEpsilon функция approximatelyEqualAbsRel() правильно обрабатывает маленькие входные значения.

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

Теги

C++ / CppLearnCppДля начинающихОбучениеОператор (программирование)ПрограммированиеЧисловые типы с плавающей точкой

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.