Работа с числами с плавающей запятой (single, double, extended) в Delphi, как и в любом другом языке программирования, может привести к неожиданным результатам из-за особенностей их представления в памяти компьютера. Эта статья посвящена проблемам сравнения и утверждений (assertions) для чисел с плавающей запятой, а также предлагает решения на основе Object Pascal (Delphi).
Суть проблемы:
Числа с плавающей запятой представляются в двоичной системе с ограниченным количеством бит. Это означает, что не все десятичные числа могут быть точно представлены в двоичном формате. Например, число 0.1 не имеет точного двоичного представления. В результате, при выполнении операций с числами с плавающей запятой накапливаются ошибки округления, что может привести к неверным результатам сравнения.
Рассмотрим пример, приведенный в исходном контексте:
procedure Test();
var
number: single;
begin
number := 0.96493138416; // Слишком длинное число для single precision
// Assert(number = 0.96493138416); // Провалит тест, это нормально
number := RoundTo(number, -3);
Assert(number = 0.965); // Провалит тест
end;
В этом примере, несмотря на то, что number должно быть равно 0.965 после округления, утверждение Assert(number = 0.965) проваливается. Это связано с тем, что number не содержит точное значение 0.965 из-за ошибок округления.
Решения:
Простое сравнение на равенство (=) для чисел с плавающей запятой практически никогда не является надежным способом проверки. Вместо этого, необходимо использовать методы, учитывающие погрешность представления.
1. Сравнение с использованием допуска (tolerance):
Самый распространенный подход - сравнение на равенство с определенным допуском. Вместо проверки на точное равенство, мы проверяем, находится ли разница между двумя числами в пределах заданного допуска.
Этот код определяет процедуру AssertFloat, которая проверяет, находится ли разница между value и should в пределах 0.00001. Этот подход прост в реализации, но требует выбора подходящего значения допуска. Слишком маленький допуск может привести к ложным срабатываниям, а слишком большой - к пропуску ошибок.
2. Функция SameValue:
Delphi предоставляет функцию SameValue в модуле System.Math, предназначенную для сравнения чисел с плавающей запятой с учетом погрешности.
uses
System.Math;
procedure Test();
var
number: single;
begin
number := 0.96493138416;
number := RoundTo(number, -3);
Assert(SameValue(number, 0.965, 0.001)); // Успешно
end;
SameValue принимает два числа и необязательный параметр Epsilon, который определяет допустимую разницу. Если Epsilon не указан, используется значение по умолчанию, которое зависит от типа данных и величины сравниваемых чисел.
Критика SameValue и альтернативные подходы:
Несмотря на удобство, SameValue имеет свои недостатки, которые были отмечены в исходном контексте:
Фиксированный допуск: Использование фиксированного допуска может быть неадекватным для чисел разных порядков. Например, допуск в 0.001 может быть приемлемым для чисел порядка единиц, но слишком малым для чисел порядка миллионов.
Неочевидное значение допуска по умолчанию: Значение Epsilon по умолчанию, используемое SameValue, может быть недостаточно точным или, наоборот, слишком строгим в зависимости от конкретной задачи.
"Убийство щенков": (Шутка Дэвида Хеффернана) Неправильное использование SameValue может привести к неверным результатам и, как следствие, к ошибкам в программе.
Альтернативные подходы:
Относительный допуск: Вместо фиксированного допуска можно использовать относительный допуск, который определяется как процент от величины сравниваемых чисел.
function AreApproximatelyEqual(const a, b: Extended; const relativeTolerance: Double): Boolean;
begin
Result := Abs(a - b) <= Max(Abs(a), Abs(b)) * relativeTolerance;
end;
procedure Test();
var number: single;
begin
number := 0.96493138416;
number := RoundTo(number, -3);
Assert(AreApproximatelyEqual(number, 0.965, 0.001)); // 0.1% допуск
end;
Этот подход более устойчив к изменению масштаба чисел.
Умножение на степень 10 и сравнение целых чисел:
procedure Test();
var number: single;
integerValue, expectedIntegerValue: Integer;
begin
number := 0.96493138416;
number := RoundTo(number, -3);
integerValue := Trunc(number * 1000); // Умножаем на 10^3
expectedIntegerValue := 965;
Assert(integerValue = expectedIntegerValue);
end;
Этот метод подходит, если вы знаете количество знаков после запятой, которые важны для сравнения. Однако, необходимо быть осторожным с возможными переполнениями при умножении на большие степени 10. Rollo62 справедливо отмечает, что этот подход может вызывать "плохое чувство" из-за риска переполнения.
Рекомендации:
Избегайте прямого сравнения на равенство (=) для чисел с плавающей запятой.
Используйте сравнение с допуском (tolerance).
Рассмотрите возможность использования относительного допуска вместо фиксированного.
Будьте внимательны при выборе значения допуска. Он должен быть достаточно малым, чтобы не пропускать ошибки, но достаточно большим, чтобы учитывать погрешность представления.
Понимайте ограничения SameValue и используйте ее с осторожностью, явно указывая параметр Epsilon.
В критически важных местах, где требуется высокая точность, рассмотрите возможность использования альтернативных типов данных, таких как Currency (для денежных расчетов) или фиксированная запятая (если это применимо).
Тщательно тестируйте код, работающий с числами с плавающей запятой, чтобы выявить и исправить возможные ошибки округления.
Помните о fun fact, что начиная с 2^24 (для Single) добавление 1 не изменит число.
Заключение:
Работа с числами с плавающей запятой требует понимания их особенностей и ограничений. Использование правильных методов сравнения и утверждений поможет избежать ошибок и обеспечить надежность ваших программ на Delphi. Выбор конкретного подхода зависит от конкретной задачи и требований к точности. Не забывайте о важности тестирования и анализа результатов, чтобы убедиться в корректности работы вашего кода.
В Delphi работа с числами с плавающей точкой требует особого внимания при сравнении и утверждениях из-за ошибок округления, для чего рекомендуется использовать сравнение с допуском или функцию SameValue, помня об их ограничениях.
Комментарии и вопросы
Получайте свежие новости и обновления по Object Pascal, Delphi и Lazarus прямо в свой смартфон. Подпишитесь на наш Telegram-канал delphi_kansoftware и будьте в курсе последних тенденций в разработке под Linux, Windows, Android и iOS
Материалы статей собраны из открытых источников, владелец сайта не претендует на авторство. Там где авторство установить не удалось, материал подаётся без имени автора. В случае если Вы считаете, что Ваши права нарушены, пожалуйста, свяжитесь с владельцем сайта.