?

Log in

No account? Create an account
 
 
17 Сентябрь 2009 @ 19:32
Это страшное слово setlocale.  
Обещанная история после возвращения из командировки.
История эта случилась достаточно давно, но до сих пор она продолжает преследовать меня страшными кошмарами, связанными в основном с локализациями наших замечательных игр. Об этих проблемах многие даже и не задумываются, а, тем не менее, они могут серьезно подорвать вашу уверенность в светлом будущем.

В начале разработки Недетских гонок у нас была «прогрессивная» по тем временам система балансировки параметров – существовала группа ini файлов, которые содержали строчки вида speed=1.25. В процессе игры можно было вызвать отладочное окно, в котором появлялась куча именованных трекбаров и, двигая ползунки, можно было в рантайме задавать разные параметры, отсматривать их в игре и записывать обратно в файл.

Система действительно была очень прогрессивной (по крайней мере, по сравнению с const float speed = 1.25), вот только мы не сразу додумались держать параметры под source control на локальном диске – до этого все параметры брались с сервера.

Как-то раз прибегает ко мне взмыленный программист и сообщает, что на сервере потерлись все параметры. Причем потерлись интересным образом – потерялись все дробные части коэффициентов. То есть тот же самый speed=1.25 превратился в speed=1. И так далее. Как обычно, я беру отладчик в зубы и начинаю проверяться.

Довольно быстро выясняется, что форматированная запись sprintf() работает как прежде.  Но вот только вместо десятичной точки при сохранении ini подставляется запятая. Быстрая проверка на старте игры показывает, что там сохраняется точка. Дальше методом половинного деления выясняется, что проблема заключается в инициализации библиотеки поддержки сети. Ну и бага проявляется только на ряде машин, на которых установлена сетевая карта такая-то, и если заблокировать сетевую карту в диспетчере устройств, то запятая не появляется.

После того, как в ini файл проникла запятая, следующее сканирование файла считывает его до пресловутой запятой, и вместо ожидаемой точной настройки переменной там оказывается обрезанное значение. Ну и последующее сохранение файла приводит к безвозвратной потере дополнительных значащих цифр.

Парился я с этой ошибкой довольно долго. Лазая по дизассемблерному sprintf(), дошел даже до инструкции, в которой брался символ из переменной __digitalpoint. В нем действительно была на старте игры записана точка, которая потом превращалась в запятую. Момент превращения был связан с подгрузкой в игру файла net.dll, который отвечал как раз за сетевую карту.

Только случайная идея помогла натолкнуться на решение проблемы. Оказалось, что баг не воспроизводится, если в Regional Settings заменить Russian на English. И действительно, для России разделителем считается запятая, для США – точка. Но почему какая-то долбанутая dll меняет мне точку в основном приложении.

Два простых теста позволяют понять, почему это происходило. Тест номер 1 дает возможность проверить это в игре:

printf("Number: %f\n", 1.123817);
setlocale(LC_ALL, "Russian");
printf("Number: %f\n", 1.123817);

Тест номер 2 заключается в том, чтобы разместить setlocale() внутри dll-ки и посмотреть, что происходит. И тут начинается интересное.

Если кодогенерация приложения была Multithread, то __digitalpoint для exe и для dll файла – это разные переменные в разных файлах. Если кодогенерация приложения была Multithread Dll, то __digitalpoint для exe и для dll файла находится в одном и том же файле msvcrt.dll.

Фанаты плюсов спросят меня – а что будет, если воспользоваться std::cout? Ответ – она не зависит от локали и всегда пишет десятичную точку (Я проверял на MSVC 2005, можете взглянуть в Microsoft Visual Studio 8\VC\crt\src\xlocnum в строку 1104). Только вот хорошо это или плохо? И второй вопрос – зачем DLL файл вызывал setlocale() внутри себя?

Я, конечно, понимаю разработчиков драйверов. У них тоже есть баги, есть семьи, и надо сдавать проекты в срок. Поэтому они, не мудрствуя лукаво, взяли настройки Regional Settings из панели управления и передали их в CRT setlocale() чтобы им жилось хорошо. А вот зачем они это сделали – совсем другой вопрос.

Желающим разобраться в нем, могу предложить еще один тест. Сделайте Multibyte приложение следующего вида:

int _tmain(int argc, _TCHAR* argv[])
{
    {
        std::ofstream os( "C:\\Игры\\test1.dat" );
    }

    setlocale(LC_ALL, "Russian");

    {
        std::ofstream os( "C:\\Игры\\test2.dat" );
    }
    return 0;
}

Потом создайте каталог «Игры» на диске C и запустите проект. Удивлены? А нет ничего странного.

Дело все в том, что если у вас до сих пор ANSI-шное приложение, то оно крайне недружелюбно ведет себя с современными операционками. Если вы получили char* указатель на имя файла (ну или std::string имя файла), то единственное, что вы можете сделать с ним – это дрожащими руками донести его до следующей функции, где оно используется и, помолясь, отдать его туда. Шаг вправо, шаг влево от этой секвенции – расстрел. Изменение настроек в Regional Settings во вкладке Language for non-Unicode programs – расстрел через повешение. Попытка применить MultiByteToWideChar() – обязательная молитва с выбором правильного CodePage.

Так а что же происходит тут? Зайдите в конструктор std::ofstream и пройдите до файла Microsoft Visual Studio 8\VC\crt\src\fiopen.cpp. Вызываемая из конструктора std::ofstream  функция _Fiopen(), оказывается, … внимание!... переводит строку методом mbstowcs_s(). А вот она уже зависит (почему то в отличие от std::cout) от текущей локали. И если у вас не дай бог в проекте встречается и std::ofstream, и MultiByteToWideChar() на текущую кодовую страницу Regional Settings, то без setlocale() вы ничего не загрузите.

Да, так что в итоге сделали мы? Мы тогда решили проблему просто – поменяли кодогенерацию с Multithread Dll на Multithread. Но Dll кодогенерацию я не люблю до сих пор. А проекты стараюсь писать на юникоде сразу. И даже на юникоде стараюсь не приближаться к std::ofstream – а то не ровен час, рванет в неожиданном месте – вдруг очередному мидлварю ну очень потребуется setlocale(LC_ALL, "French")?

Ну и традиционно ненавижу библиотеки (CRT в том числе), чья работа критическим образом зависит от недетерминированных функций, которые вызвались много много минут назад, когда ваше приложение радостно шуршало диском и поднималось в оперативную память.
Метки: ,
 
 
Местонахождение: office
 
 
 
(Анонимно) on Сентябрь, 17, 2009 16:25 (UTC)
Но Dll кодогенерацию я не люблю до сих пор
История интересная, но возникает 2 вопроса:
1. при чем здесь Dll кодогенерация? проблема явно в том, что всё приложение свято наделялось на то, что ф-ия sprintf (в исходники которой можно легко взглянуть) будет ставить точку. на чем основана подобная уверенность, сказать трудно - сколько раз возникали конфликты .vcproj из-за "8.00" и "8,00"?
CRT я тоже не люблю именно из-за локалей, и все ф-ии, которые хоть как-то связаны с локалями, объявляю deprecated или вообще error.
опять же нет гарантии, что сторонняя либа, которая вкомпилится в длл-ку, не вызовет что-нибуть неприличное, которое сломает все точки и запятые. тут уже никакая Multithreaded не спасет.

мы подобную проблему решали жесткой установкой нужной локали непосредственно перед вызовом функции.

2. юникод содержит в себе не меньше подводных камней, особенно если есть библиотека, которая с ним не дружит. или же есть любители поиспользовать UTF8.

С уважением, Алексей Буйницкий, 4А-Геймс
Дядя Димаddima on Сентябрь, 17, 2009 16:53 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
1. Ну, я после этой истории уже не надеюсь за точки и запятые. А dll кодогенерация при том, что у побочных библиотек, которые могут грузиться пачками (например, кодеки ax для ddraw, хендлеры эксплорера для OpenFile() и т.п.) возможностей сломать мне локаль в msvcrt.dll гораздо больше, чем у того, что мною же слинковано вместе со мной.
2. Согласен. Но в ANSI приложении таких подводных камней ИМХО больше. Плюс непреодолимая плотина, когда думаешь о китайской локализации.
(Анонимно) on Сентябрь, 17, 2009 17:00 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
1. к сожалению, любая либа может это поломать. разве что если прилинковывается 100% проверенная (правда, без исходников как такое может быть?). но гарантии все равно нет. с этой позиции Multithreaded просто скрывает проблему до поры до времени.
2. мы как-то сподобились... это ведь не проблема чар-сета. На самом деле в игре только английские "ключи", которые через табличку переводятся в массив индексов шрифтовых символов. Минус конечно то, что нельзя увидеть в отладчике :)
С уважением, Алексей Буйницкий, 4А-Геймс
(Анонимно) on Сентябрь, 17, 2009 17:13 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
на самом деле меня абсолютно никаких вопросов - все сделано правильно. но меня взволновала эта фраза "Мы тогда решили проблему просто – поменяли кодогенерацию с Multithread Dll на Multithread". очень не люблю решения проблем таким путем. это все равно, что проблему с дизайном решать включением RTTI и dynamic_cast-ов по всему проекту. цель не оправдывает средства совсем.

С уважением, Алексей Буйницкий, 4А-Геймс
Дядя Димаddima on Сентябрь, 17, 2009 17:26 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Я кстати полностью согласен. Я тоже не люблю такие решения, и, наверное, то, что оно отложилось у меня в голове на целых 8 лет, говорит о том, что оно не было из разряда "сделали и забыли" :).
Просто в тот момент времени имелось следующее:
- наработанная С кодобаза на 200К строк.
- проблема с чужой DLL, которая ломала нам сборку.
Решать проблему обратным setlocale() не хотелось - раз люди вызвали ее себе, то, наверное, она им зачем то нужна. Перелопатить весь свой код в короткие сроки тоже нельзя было. Решение в кодогенерации потом было укреплено своим sprintf (http://ddima.livejournal.com/42616.html), а потом и своими строками (http://ddima.livejournal.com/60124.html).

Но в любом случае multithread dll оставляет слишком много лазеек для "левых" dll что-то исправить (не обязательно локаль) в настройках твоего CRT. А вот статически линкуемое middleware в конце концов можно проверить по логу линкера.
Дронтdrontik on Сентябрь, 18, 2009 13:12 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Вот тут совершенно согласен. multithread dll может привести к намного более серьезным проблемам, чем внезапно поменявшаяся локаль.
Например, оверрайды некоторых CRT функции на этапе линковки, включая malloc/free, чтобы они проходили через кастомный аллокатор. С CRT в DLL это если и возможно, то приведет к невообразимым последствиям (а я подозреваю, что это невозможно в принципе).
Если к этому добавить другие DLL, а особенно если они тоже играются с аллокаторами - будет мрак и ужас. Пусть уж лучше в своей копии песочницы играются.
Дронтdrontik on Сентябрь, 18, 2009 09:51 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
А в чем проблема с UTF-8?
Как раз основное достоинство этой кодировки - тяжело найти библиотеку, которая "не дружит", т.к. для большинства задач с UTF-8 строкой можно работать как с обычной NULL-terminated. strcat/strcpy/sprintf и т.п.
Да, функции типа "посчитать кол-во символов" нетривиальны - в смысле, 3 строчки кода вместо одной - но они нужны только уже на этапе вывода текста в GUI. Edit Box для utf-8 тоже сложнее, но не сильно. У нас несколько проектов вышло именно использующих UTF-8, в том числе один с японской локализацией (x-blades, если кто не в курсе), и с кодировками проблем не было вообще.
Дядя Димаddima on Сентябрь, 18, 2009 09:58 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Для меня проблемы с UTF8 следующие:
1. Эта кодировка представляется как char* и его аналоги, что может приводить (и наверняка приводит) к ряду ошибок с использованием таких указателей наряду с обычными мультибайтовыми. Например, внутри стандартного API.
2. Нетривиальность поиска таких ошибок, отлаживаясь только на ASCII текстах.

Ну а если начинать городить огород с struct charutf8, чтобы исключить невидимую миграцию мультибайта и UTF8 друг в друга, то и использование wchar_t окажется не заметно сложнее.
Дронтdrontik on Сентябрь, 18, 2009 10:11 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Ну, кстати, довольно распространенный способ - переменные, содержащие utf-8 строки, объявлять как unsigned char * - чтобы не путать.

Использовать _другие_ мультибайтные кодировки - не стоит вообще. Т.е. все общение с API должно происходить через wchar_t (благо конвертация UTF8<->UTF16 тоже тривиальна).
Дядя Димаddima on Сентябрь, 18, 2009 10:41 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Я знаю про этот способ.
Но давай взглянем на оба варианта с точки зрения пайплайна.
- меняем char на unsigned char
- начинаем проходиться по коду в поисках варнингов/эрроров.
- меняем вызовы, для системных функций добавляем врапперы-конвертаторы utf8-unicode и т.п.

Вопрос - чем замена unsigned char отличается от замены на wchar_t? Имхо второй вариант дает такие же предупреждения и ошибки, для него в большинстве случаев есть уже готовые функции как в CRT, так и в API. А результат - практически такой же, за исключением необходимости регулярно гонять ни в чем не виноватые строчки из unicode в utf8 и обратно...
Дронтdrontik on Сентябрь, 18, 2009 10:46 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Вот как раз на это - ответил ниже.
Не все строки одинаковы. Строки, которые идут в API - отдельно, и конвертировать их не надо. Как они пришли из API, так в него и уйдут. Строки, написанные где-то в данных игры - в том формате, в котором они удобны игре - в API уходить не должны никогда. Тогда и косяков не будет
Дронтdrontik on Сентябрь, 18, 2009 10:42 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Да, и еще один момент. "C:\\Игры\\test2.dat" - это серьезный дизайнерский проеб. (Не, я понимаю, что тут это для примера написано).
Единственная причина, по которой в API может уйти не-аскишная строка - это если она из API и пришла. Например, мы попросили у системы текущий каталог или путь в "мои документы".
Такие места обычно легко локализуются (в конце концов, это место явно platform specific, ну нет на консолях "моих документов"). И - в таких местах можно хранить эти строки в том виде, в котором они удобны _системе_, а не нам. В случае с win32 - надо брать их через wchar_t функции, обрабатывать как wchar_t, и файлы открывать там через CreateFileW.
На других платформах в этом месте будут другие функции и другие кодировки.

Мешать при этом тексты игры (те, которые видит или даже вводит игрок) с текстами "системными" - нельзя, это отличный способ собрать все грабли какие возможно. А если их не мешать - то UTF-8 для _игровых_ текстов отлично подходит, и никак не зависит от того, как строки представлены в окружающем API.
Дядя Димаddima on Сентябрь, 18, 2009 10:54 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
> Например, мы попросили у системы текущий каталог или путь в "мои документы".
Я в следующем блоге уже приводил пример такого проеба:
getcwd() -> ifstream. Это все разумеется справедливо только для PC. То, что например в двух местах для PSP требуется Shift-JIS, я даже не упоминаю - перекрестился, засунул, забыл.

Но использовать в разных компонентах utf-8, где-то multibyte, а в системных вызовах - unicode - мне все же кажется слишком стремным )
Дронтdrontik on Сентябрь, 18, 2009 11:05 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Так все равно придется. Вот пример с ПСП как раз показательный.

Ну тупо, есть мультиплатфоменный код. На одной из наших трех платформ есть ANSI или wchar_t в API. На второй в общем-то только wchar_t. На третьей - как раз utf-8 в API, а wchar_t нет и в помине. Как тут использовать один тип данных?

Использовать мультибайт который не utf-8 - не стоит точно (если конечно он не требуется системе, как вот в примере). Использовать не юникод - вообще стремно. Получается, что-то одно придется выбрать - utf-8 или utf-16, других вариантов особо нет. Предпочтений в общем-то тоже нет (см. выше, у одной target платформы один вариант "родной", у другой - второй). Получается, надо использовать то, что удобнее. Я считаю, что utf-8 удобнее. Ну и тупо меньше памяти занимает в большинстве случаев (т.к. текста, влезающего в 1 байт, обычно сильно больше, чем текста трехбайтового. Если конечно это не japanese-only игра).
balmerdxbalmerdx on Сентябрь, 20, 2009 06:13 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
"Но использовать в разных компонентах utf-8, где-то multibyte, а в системных вызовах - unicode - мне все же кажется слишком стремным )"

Просто ты не любишь utf8 :)
Если уж говорить о корректности, то надо читать стандарт, в котором написанно - в utf16 тоже есть составные символы (которые 2 word занимают).
Дядя Димаddima on Сентябрь, 21, 2009 03:09 (UTC)
Re: Но Dll кодогенерацию я не люблю до сих пор
Дим, а я никогда и не отличался большой любовью к 100% покрытию стандарта :).
Если бы речь шла про китайский Abby Lingvo, то я бы еще подумал о полной поддержке Unicode. Но поскольку речь идет об игре с известным сетом символов, то для меня во-первых, достаточно поддержки того, что есть сейчас, а во-вторых, оценивая затраты на портирование multibyte в unicode и в utf8, я понимаю, что они примерно одинаковы, но utf8 из-за того, что это тот же char* (или unsigned char*) может быть более забагованым.
Zeuxzeux on Сентябрь, 17, 2009 16:42 (UTC)
Про std::cout. Думаю, если вредная DLL скажет std::cout.imbue(std::locale("Russian"));, то все точно так же навернется. Просто метод установки локали другой для стримов.

Мне тут в pugixml некоторое время назад файлили баг про то что atoi парсит запятую %)
mihapromihapro on Сентябрь, 17, 2009 18:03 (UTC)
В мемориз занёс. Ну и на опыте уже получили грабли.
Семенsim0nsays on Сентябрь, 17, 2009 19:31 (UTC)
Дима, как командировка-то?
Дядя Димаddima on Сентябрь, 17, 2009 19:44 (UTC)
Я правильно понимаю, что по первому пункту (как программисты MSVC мучаются с локалями) возражений нет? :) :) :)
Семенsim0nsays on Сентябрь, 17, 2009 19:49 (UTC)
Патриотизм всегда в приоритетах!
(Удалённый комментарий)
Дядя Димаddima on Сентябрь, 18, 2009 08:29 (UTC)
Честно говоря, с трудом, потому что с Linux работал очень и очень мало. Реакция приложения на setlocale() будет аналогичной первому примеру. Кодогенерация AFAIK всегда multithread (без dll).
Про работу ofstream и установку системной локали - затрудняюсь сказать. Проще проэкспериментировать )
Чеширский Котchecat on Сентябрь, 17, 2009 22:43 (UTC)
локали придумал сатана, без базара.

Или же компьютеры делаются из кремния, добытого на развалинах Вавилонской башни.
Дмитрий Ивашкинivashkin on Сентябрь, 18, 2009 08:21 (UTC)
А зачем было "лазать по дизассемблерному sprintf()", если и так понятно, что дело в locale? :)
Дядя Димаddima on Сентябрь, 18, 2009 08:27 (UTC)
Ну вот в тот момент мне это было неочевидно.
Тем более что setlocale() стал вызываться из чужой dll после инициализации сетевой библиотеки - сходу никак нельзя было предположить такого аццкого влияния на код.
На самом то деле отладка была довольно короткой. Да и сам пост - это, скорее, по моей старой привычке просто оформленная мораль - юзайте, наконец уже, юникод или тщательно следите за локалями в ANSI проекте. Потому что ежемесячные проблемы "Дима, у нас опять падает венгерский билд" уже начинают надоедать...
Maratmaratyszcza on Сентябрь, 19, 2009 08:37 (UTC)
Меня удивляло, почему Excel в CSV-файлах использует точку с запятой вместо точки. Причём не только при сохранении, Excel также отказывается открывать файлы, в которых используется запятая для разделении значений. Это тем более странно, что в стандарте CSV прописана именно запятая (своим ASCII-кодом прописана, прошу заметить)! А потом я как-то сохранил в CSV на машине с американской локалью, и всё понял...