Каждый разработчик С++ рано или поздно сталкивается с особенностями конвертации дробного числа из строкового представления (std::string) в непосредственно число с плавающей точкой (float), связанными с установленной локалью (locale). Как правило, проблема возникает с различным представлением разделителя целой и дробной частей в десятичной записи числа ("," или ".").

В данной статье речь пойдет о двойственности локалей С++. Если Вам интересно, почему преобразование одной и той же std::string("0.1") с помощью std::stof() и std::istringstream во float может привести к различным результатам, прошу под кат.

Проблема


Как и во многих статьях Хабра, все началось с ошибки в коде, фрагмент которого можно свести к следующему:

float valf = std::stof(str); // где str = std::string("0.1")
std::cout << valf << std::endl; // печатает 0, а должен 0.1

«Дело в локали», — думаю я, поэтому в отладочных целях перед преобразованием дописываю строку вывода на экран действующего разделителя целой и дробной частей, ожидая увидеть там ",":

std::locale lcl; // создает копию текущей глобальной локали
const auto & facet = std::use_facet<std::numpunct<char>>(lcl);
std::cout << facet.decimal_point() << std::endl; // печатает точку!

Пару слов о представленном коде
Красивого кода ради стоит отметить, что правильнее было бы добавить проверку существования фасета:

std::locale lcl;
if (std::has_facet<std::numpunct<char>>(lcl))
{
//...
}

Подробнее про работу с фасетами и локалями в С++ можно узнать здесь: на Хабре, в документации.

Получается, что локаль установлена верная, и строка "0.1" должна преобразовываться корректно. Проверяем преобразование через std::istringstream:

float valf = std::stof(str); // где str = std::string("0.1")
std::cout << valf << std::endl; // печатает 0, а должен 0.1

std::istringstream iss(str);
iss >> valf;
std::cout << valf << std::endl; // печатает 0.1, все верно!

Получаем, что преобразование через std::istringstream работает как ожидается, в то время как std::stof() возвращает неверное значение.

Суть


В С++ существуют две глобальных локали:


При этом смена глобальной локали с помощью функции std::locale::global() меняет как STL-локаль, так и локаль С-библиотеки, в то время как функция setlocale() влияет только на вторую.

Таким образом, возможно рассогласование:

auto * le = localeconv();
std::cout <<  le->decimal_point << std::endl; // печатает запятую

std::locale lcl; // создает копию текущей глобальной локали
const auto & facet = std::use_facet<std::numpunct<char>>(lcl);
std::cout << facet.decimal_point() << std::endl; // печатает точку!

Загвоздка заключается в том, что функция из C++11 std::stof() (как и std::stod()) базируется на функции strtod() (или wcstod()) из библиотеки С, которая, в свою очередь, ориентируется на локаль С-библиотеки. Получается, что поведение С++ функции опирается на локаль С-библиотеки, а не на локаль STL, как ожидается.

Заключение


Функции C++ STL в своей работе могут использовать функции С-библиотеки, что может приводить к неожиданному результату, в частности, в случае рассогласования глобальных локалей STL и С-библиотеки. Необходимо иметь это в виду.

В моем конкретном случае под *nix был «виноват» класс QCoreApplication библиотеки Qt, который при инициализации вызывает setlocale(), тем самым приводя к возможному рассогласованию описанных локалей.

P.S. Как многие верно подметят, библиотека Qt обладает своими средствами преобразования строки в число, как и своей собственной глобальной локалью (QLocale). Описанная ситуация возникла при интеграции кода из проекта, использующего только STL, в Qt-проект.

Комментарии (14)


  1. iCpu
    07.12.2017 11:47

    Вообще, это больше похоже на дырку в стандарте. Интересно, что в нём написано по этому поводу.
    Вы уже забросили жука на bugreports.qt.io?


    1. al_sh
      07.12.2017 11:53

      а кьют тут причем? Вроде в контексте STL статья


    1. Roottyck Автор
      07.12.2017 16:18

      Не уверен, что в QCoreApplication это именно баг, тем более, что про использование setlocale() сказано в его документации.

      С другой стороны, на мой взгляд правильнее было бы вместо setlocale() использовать std::locale::global(). Тогда бы не было проблемы, описанной мной в статье. Гляну у них в багрепорте по этому поводу, спасибо. Может кто уже предлагал.


  1. NeoCode
    07.12.2017 11:48

    Откуда кстати вообще взялся этот бред что в качестве разделителя некоторые используют запятую?


    1. nerudo
      07.12.2017 12:07

      И месяц вперед дня пишут. А год после.


    1. heleo
      07.12.2017 12:40

      Национальные стандарты (привычки) как правило. Если посмотреть особенности locale доступных в системе можно обнаружить много интересного.
      К примеру, недавно пришлось менять свойство локали выдающей дату, потому как преобразование даты как упоминалось выше шло неверно: 20.01.00 а нужно было 2000.01.20.
      Ещё сталкивались с количеством нулей вывводимых после запятой для дробных чисел, так же настраивается в locale.


    1. saege5b
      09.12.2017 01:16

      Кто-то привык со времён пиратской английской виндовс, многие из них ещё Чикаго помнят.
      Кому-то всё равно.
      В одной как-бы госструктуре, лет 10 назад, при ежемесячных обновлениях, регулярно меняли разделители… точка, запятая, таб, точка с запятой… и каждый раз при обновление они конавертировали БД.


    1. EndUser
      10.12.2017 11:06

      «Некоторые» — это мы, xСССР :-D
      ru.wikipedia.org/wiki/Десятичный_разделитель

      Ну и, паровозом, en.wikipedia.org/wiki/Date_format_by_country


  1. al_sh
    07.12.2017 11:51
    +1

    а мне sprintf почему то нравится для этих целей.


    1. nick758
      07.12.2017 16:11

      sscanf. sprintf — это из числа в строку.


  1. kovserg
    07.12.2017 14:34

    Еще забавные грабли с локалью
    std::ifstream(name); // может не открыть если в путях неудачные символы
    fopen(name,«rb»); // открывает тот же файл

    И еще такой вопрос: как менять локаль для отдельного потока?


  1. fallenworld
    07.12.2017 15:36

    Да, наталкивался на подобную проблему.
    У меня правда был atoi, который должен пропускать пробельные символы в начале. И был там в начале не просто пробел, неразрывный пробел он же nbsp. Так вот в 1251 локали atoi неразрывный пробел понимает, а в C-локали не понимает. Но я это узнал уже после нескольких кругов ада при выяснении — почему на windows работает, а на linux нет. А потому что на linux была честная C локаль, а на windows подключена libdjvu, которая меняла локаль при старте.
    К чему я это все:
    Вводят наконец то не зависимые от локали преобразователи std::from_chars


    1. Roottyck Автор
      07.12.2017 16:20

      Спасибо за наводку. Теперь С++17 я жду с еще большим нетерпением!


  1. andy_p
    07.12.2017 19:00

    А вот еще интересная задачка, с которой я когда-то столкнулся — как ввести десятичную дробь и узнать число цифр после запятой, то есть точность этого числа?