Проверка границ чисел с плавающей точкой iOS на облачном Mac
Индикатор прогресса иногда перестаёт обновляться, а на графике внезапно появляются пустые участки? Причина может быть не в потоке интерфейса, а в NaN, пришедшем с предыдущего этапа обработки. Деление показания датчика на диапазон измерений или выполненного объёма на общий способно дать недопустимый результат. На облачном Mac от ArmShell можно сначала вынести эти вычисления в отдельный пакет Swift, проверить граничные случаи и лишь затем подключить реализацию к проекту iOS.
Сначала определите числовой контракт, а не подбирайте допуск
В статье рассматривается одна конкретная задача: нормализация неотрицательного показания в диапазон от нуля до единицы. Входные значения должны быть конечными, диапазон измерений — положительным, а результат — конечным и не выходящим за допустимые границы. Для некорректных данных возвращается ошибка, без автоматического ограничения результата границами диапазона. Нулевое показание допустимо, а отрицательный ноль всегда преобразуется в положительный.
Здесь легко упустить важную деталь: проверка value < 0 || value > 1 не отсекает NaN. Все сравнения порядка с NaN возвращают false, поэтому программа может продолжить обработку, считая результат допустимым.
Конечность значения, допустимость в предметной области и приближённое равенство — три разных уровня проверки. Увеличение допуска в тестах не исправляет некорректные входные данные.
Заранее определите и стратегию обработки ошибок. Интерфейс может показывать сообщение «Данные недоступны», но не должен незаметно подменять ошибку нулём: иначе пользователь не сможет отличить настоящее нулевое значение от сбоя чтения.
Создайте тестовый пакет без зависимости от симулятора
Сначала убедитесь, что инструменты командной строки доступны, затем создайте пакет во временном каталоге. Следующие действия не меняют проект приложения и не требуют запуска графического сеанса:
xcrun swift --version
root="$(mktemp -d)"
cd "$root"
swift package init --type library --name NumericBoundary
Замените содержимое Sources/NumericBoundary/NumericBoundary.swift следующим кодом:
public enum LevelError: Error, Equatable {
case invalidInput
case invalidResult
}
public func normalizedLevel(
_ raw: Double,
scale: Double
) throws -> Double {
guard raw.isFinite, scale.isFinite,
raw >= 0, scale > 0 else {
throw LevelError.invalidInput
}
let ratio = raw / scale
guard ratio.isFinite, ratio >= 0, ratio <= 1 else {
throw LevelError.invalidResult
}
return ratio == 0 ? 0.0 : ratio
}
Проверки одних только входных данных недостаточно. Деление двух конечных чисел всё равно может дать бесконечность — например, если разделить очень большой числитель на очень маленький положительный знаменатель. Конечный результат, выходящий за диапазон измерений, тоже должен приводить к ошибке. Нельзя перекладывать защиту от ошибок вычислительного слоя на компоненты интерфейса.
Эта реализация допускает обычное округление при вычислениях с плавающей точкой. Если предметная область требует минимального значимого показания, для него нужен отдельный порог. При делении очень малых положительных чисел возможно обнуление из-за потери порядка. В этом примере оно допустимо: требование «конечный результат» не означает «полное отсутствие потери точности».
Покройте ошибочные сценарии контрпримерами
Замените содержимое стандартного тестового файла следующим кодом. В каждом случае проверяется категория ошибки, чтобы тест не прошёл ошибочно, если функция выбросит ошибку по другой причине:
import XCTest
@testable import NumericBoundary
final class NumericBoundaryTests: XCTestCase {
func testInvalidInputs() {
let cases: [(Double, Double)] = [
(.nan, 1),
(.infinity, 1),
(-.infinity, 1),
(1, .nan),
(1, .infinity),
(1, -.infinity),
(-1, 1),
(1, 0),
(1, -0.0),
(1, -1)
]
for (raw, scale) in cases {
XCTAssertThrowsError(
try normalizedLevel(raw, scale: scale)
) { error in
XCTAssertEqual(error as? LevelError, .invalidInput)
}
}
}
func testInvalidResults() {
let cases: [(Double, Double)] = [
(2, 1),
(.greatestFiniteMagnitude, .leastNormalMagnitude)
]
for (raw, scale) in cases {
XCTAssertThrowsError(
try normalizedLevel(raw, scale: scale)
) { error in
XCTAssertEqual(error as? LevelError, .invalidResult)
}
}
}
func testValidBoundaries() throws {
XCTAssertEqual(try normalizedLevel(0, scale: 1), 0)
XCTAssertEqual(try normalizedLevel(1, scale: 1), 1)
XCTAssertEqual(
try normalizedLevel(1, scale: 10),
0.1,
accuracy: 1e-12
)
let zero = try normalizedLevel(-0.0, scale: 1)
XCTAssertEqual(zero, 0)
XCTAssertEqual(zero.sign, .plus)
}
}
При обычном сравнении на равенство отрицательный ноль равен положительному. Поэтому нужно дополнительно проверить sign, чтобы подтвердить, что правило нормализации действительно сработало. Не используйте в ошибочных сценариях и утверждение «результат не равен NaN»: NaN не равен даже самому себе, так что такая проверка ничего не доказывает.
Привяжите допуск к масштабу задачи
В примере выходной диапазон фиксирован: от нуля до единицы, поэтому сравнение по абсолютной погрешности достаточно наглядно. Значение 1e-12 выбрано для тестирования этого небольшого вычисления, а не в качестве универсального стандарта точности для любых датчиков, графических или статистических задач.
Если вычисляемые значения различаются на много порядков, сначала убедитесь, что оба значения конечны, а затем используйте сочетание абсолютного и относительного допусков в соответствии с требованиями задачи:
abs(actual - expected)
<= max(absoluteTolerance,
relativeTolerance * max(abs(actual), abs(expected)))
Оба допуска тоже должны быть конечными и неотрицательными. При экстремальных величинах вычитание или умножение в правой части может вызвать переполнение, поэтому эту формулу нельзя без дополнительных мер считать универсальной безопасной библиотечной реализацией.
Вблизи нуля решающую роль играет абсолютный допуск, а для больших чисел — относительный. В тестовом коде следует указывать источник порога: например, разрешение устройства или бюджет погрешности алгоритма. Не стоит просто увеличивать допуск до тех пор, пока тест не пройдёт. Не нужно и предварительно форматировать числа в десятичные строки для сравнения: это скрывает реальные различия.
Подключите тесты к конвейеру и убедитесь, что проверка блокирует ошибки
В каталоге пакета выполните:
swift test
swift test -c release
Тесты нужно запускать и в отладочной, и в релизной конфигурации. Для проверок в рабочем коде используются guard и выбрасывание ошибок, а не assert, который может быть удалён в оптимизированной сборке. Конвейер должен сохранять код завершения команды и останавливать дальнейшую поставку, если тесты не прошли.
Затем намеренно нарушьте проверку: временно удалите raw.isFinite из проверки входных данных, убедитесь, что тест с NaN падает из-за изменения категории ошибки, и восстановите код. Это подтвердит, что тест действительно охватывает входную проверку, а не просто вызывает функцию.
При подключении к приложению рабочий код должен вызывать ту же реализацию. Не поддерживайте отдельную копию в тестовом пакете. Достаточно записывать версию инструментов, идентификатор коммита и упавший тестовый случай — загружать реальные показания пользователей не требуется. Итоговые критерии приёмки: недопустимые входные данные явно приводят к ошибке, корректные граничные значения стабильно проходят тесты, допуски обоснованы, а в релизной конфигурации действует тот же контракт.
Часто задаваемые вопросы
Отсекает ли NaN проверка «меньше нуля или больше единицы»?
Нет. Оба сравнения для NaN возвращают false. Сначала проверяйте isFinite, затем допустимый диапазон.
Нужно ли проверять результат, если оба входных числа конечны?
Да. Деление конечных чисел может дать бесконечность. Повторно проверяйте isFinite и допустимый диапазон результата.
Подготовьте облачный Mac к следующей сборке
Выберите модель и срок аренды под свои задачи. Выделенный физический узел Apple Silicon, не виртуальная машина.
Арендовать облачный Mac