В стандартном C нет переносимого способа поймать ошибки strtod: glibc и musl ведут себя по-разному

Автор личного блога пишет продолжение своего предыдущего поста о макросе math_errhandling. Этот макрос показывает, какие механизмы обработки ошибок поддерживают функции из math.h: errno (MATH_ERRNO) и/или исключения плавающей запятой (MATH_ERREXCEPT). Новое наблюдение: от math_errhandling зависит и семейство функций, которые не лежат в math.h, преобразование строки в число с плавающей запятой: strtod, strtof, strtold, strtod32, strtod64 и strtod128. Итог разбора: в стандартном C нет переносимого способа надёжно обнаружить их ошибки.

Переполнение и потеря значимости. По описанию автора, стандарт говорит так: если math_errhandling не заявляет поддержку errno (как в musl), функции преобразования не выставляют errno при ошибке; при потере значимости (underflow) функция вообще не обязана сообщать об ошибке. Одного возвращаемого значения для обнаружения переполнения недостаточно, нужен один из двух механизмов. Автор предлагает свою попытку «стандартного» переносимого кода: очистить исключения FE_OVERFLOW и FE_UNDERFLOW, обнулить errno, вызвать strtod, а затем проверить errno == ERANGE (если math_errhandling & MATH_ERRNO) либо fetestexcept(FE_OVERFLOW | FE_UNDERFLOW). Но и это не гарантирует обнаружения потери значимости: сообщать о ней реализация вправе по своему усмотрению.

POSIX. По словам автора, POSIX не учитывает math_errhandling: при переполнении и потере значимости он всегда требует выставить errno в ERANGE. Поэтому в man-страницах Linux про эту зависимость нет ни слова, и ни strtod(3), ни спецификация POSIX strtod(3p) не отмечают это как расширение, что автору кажется заметным упущением. Совместимо ли поведение POSIX со стандартом C, неясно: для функций math.h выставлять errno независимо от math_errhandling разрешено, но описание errno в стандарте для этих функций не упоминает выставление errno при нулевом math_errhandling & MATH_ERRNO. Автор делает вывод, что поведение POSIX не соответствует стандарту C.

Некорректная строка. Для строки, которую нельзя разобрать как число, стандарт вообще не задаёт ошибку: нужно передать параметр endptr и проверить, что endptr == nptr, то есть ничего не разобрано. POSIX говорит, что при отсутствии преобразования возвращается 0, а errno может быть установлен в EINVAL; слово «может» автор трактует как implementation-defined (определяется реализацией). При этом распространённый приём, обнулить errno, вызвать strtod(s, nullptr) и проверить errno != 0, прямо рекомендует man-страница strtod(3). Но даже среди POSIX-совместимых libc он непереносим. Пример автора: вызов strtod со строкой x и печать errno. На glibc выводится 0, потому что strtod в glibc никогда не ставит EINVAL; на musl выводится 22 (EINVAL). На Linux-странице man это не документировано. По прочтению автора, поведение musl не соответствует стандарту C: errno ненулевой устанавливается в функции, у которой есть другие документированные условия ошибки, а неверный ввод для стандарта C ошибкой не является. musl могла бы защищаться тем, что в её math.h-функциях errno не выставляется, а значит условие в описании strtod якобы не применяется, но автор называет это натяжкой. Вывод: стандарту здесь нужны более ясные формулировки.

В заключении автор «для смеха» перечисляет «правильные» способы проверки. Переполнение: для POSIX, обнулить errno и после вызова проверить copysign(result, 1.0) == HUGE_VAL && errno == ERANGE; иначе, обнулить errno и вызвать feclearexcept(FE_OVERFLOW) до вызова, а после, если copysign(result, 1.0) == HUGE_VAL, проверить errno == ERANGE или fetestexcept(FE_OVERFLOW) в зависимости от math_errhandling. Для единственной известной реализации проверку math_errhandling можно пропустить. При использовании feclearexcept/fetestexcept компилятор должен знать о доступе к среде с плавающей запятой: в gcc это флаг -ftrapping-math (включён по умолчанию, кроме режима -ffast-math), а стандартный pragma STDC FENV_ACCESS ON gcc не поддерживает. Потеря значимости: для POSIX, обнулить errno и проверить result == 0.0 && errno == ERANGE (именно ERANGE, а не просто ненулевое значение); иначе, смотреть документацию конкретной реализации (поведение определяется реализацией и формально должно быть документировано, но на практике, по словам автора, не документирует его даже clang); если документации нет, остаётся ручная проверка: при result == 0.0 проверить, есть ли во входной строке ненулевые цифры до экспоненты, это сложно сделать правильно. Некорректный ввод: передать &endptr вторым аргументом и проверить endptr == nptr; на errno полагаться нельзя.

Ключевые факты

  • Стандарт C: если math_errhandling не заявляет поддержку errno (как в musl), функции strtod, strtof, strtold, strtod32, strtod64 и strtod128 не выставляют errno при ошибке, а при потере значимости ошибку сообщать вообще не обязаны.
  • POSIX всегда требует выставлять errno в ERANGE при переполнении и потере значимости независимо от math_errhandling; по мнению автора, это поведение не соответствует стандарту C.
  • Для некорректной строки стандарт ошибку не задаёт: надёжный способ, параметр endptr и проверка endptr == nptr. POSIX лишь допускает EINVAL в errno.
  • После strtod со строкой x errno на glibc равен 0, на musl, 22 (EINVAL), так что распространённая проверка errno != 0 непереносима даже между POSIX-совместимыми libc.
  • Автор заключает, что стандарту нужны более ясные формулировки, и перечисляет рецепты проверки для переполнения, потери значимости и некорректного ввода.

Почему это важно

Проверка errno после strtod выглядит как обычная практика и даже советуется в man-странице strtod(3), но, по разбору автора, на деле опирается на поведение конкретной библиотеки. Разбор показывает, что glibc и musl расходятся уже на простом случае некорректной строки (0 против EINVAL), а стандарт C и POSIX формулируют требования по-разному. Автор считает, что формулировки стандарта нужно сделать яснее.

Кому это важно

Прежде всего тем, кто на C пишет разбор чисел из текста: парсеры конфигураций, форматов данных, протоколов, и кому нужно, чтобы код вёл себя одинаково на разных libc. Также авторам библиотек стандартной библиотеки и тем, кто следит за соответствием стандарту C и POSIX.

Как это применить

По рекомендациям автора: для некорректного ввода передавайте endptr и сравнивайте его с nptr, не полагаясь на errno. Для переполнения и потери значимости под POSIX обнуляйте errno перед вызовом и проверяйте ERANGE (именно ERANGE, а не любое ненулевое значение); вне POSIX комбинируйте проверку errno и fetestexcept в зависимости от math_errhandling. Если нужно обнаружить потерю значимости без документированного поведения реализации, остаётся ручная проверка ненулевых цифр во входной строке до экспоненты.

Можно ли доверять

Это личный блог, а не документ комитета по стандартизации: выводы о соответствии или несоответствии стандарту, собственное прочтение автора («from my reading of the standard»), и он сам признаёт, что вопрос совместимости POSIX со стандартом C неясен. Из примеров приведено только сравнение glibc и musl, без номеров версий; для других библиотек результатов нет. Имя автора и дата публикации в тексте не указаны. Рецепты, авторские «попытки», не официальные рекомендации.

Риски и подводные камни

Даже «стандартная» проверка не гарантирует обнаружения потери значимости, так как сообщать о ней реализация вправе по желанию. Для fetestexcept/feclearexcept нужно, чтобы компилятор знал о доступе к среде с плавающей запятой: в gcc флаг -ftrapping-math действует по умолчанию, но не в режиме -ffast-math, а pragma STDC FENV_ACCESS ON gcc не поддерживает. Ручная проверка входных цифр сложна, и автор советует сверяться с описанием strtod в стандарте, чтобы учесть все крайние случаи.

«Мне ясно, что стандарту здесь нужны более чёткие формулировки.»

— автор блога (имя в тексте не указано)