Эти занимательные региональные настройки



Сегодня мы поговорим о региональных настройках. Но сперва — небольшая задачка: что выведет нижеприведённый код? (Код приведён на языке C#, но рассматривается достаточно общая проблематика, так что вы можете представить на его месте какой-нибудь другой язык.)

Console.WriteLine((-42).ToString() == "-42");
Console.WriteLine(double.NaN.ToString() == "NaN");
Console.WriteLine(int.Parse("-42") == -42);
Console.WriteLine(1.1.ToString().Contains("?") == false);
Console.WriteLine(new DateTime(2014, 1, 1).ToString().Contains("2014"));
Console.WriteLine("i".ToUpper() == "I" || "I".ToLower() == "i");

Сколько значений true у вас получилось? Если больше 0, то вам не мешает узнать больше про региональные настройки, т. к. правильный ответ: «зависит». К сожалению, многие программисты вообще не задумываются о том, что настройки эти в различных окружениях могут отличаться. А выставлять для всего кода InvariantCulture этим программистом лениво, в результате чего их прекрасные приложения ведут себя очень странно, попадая к пользователям из других стран.

Ошибки бывают самые разные, но чаще всего связаны они с форматированием и парсингом строк — достаточно частыми задачами для многих программистов. В статье приведена краткая подборка некоторых важных моментов, на которые влияют региональные настройки.

Совсем немного теории: в .NET все сведения об определённом языке и региональных параметрах можно найти с помощью класса CultureInfo. Если вы ранее не сталкивались с культурами, то для первичного ознакомления хорошо подойдёт этот пост. Искушённый программист, увлечённый изучением различных существующих региональных настроек, может утомиться от ручного просмотра всех CultureInfo. Лично я в какой-то момент утомился. Поэтому появилось небольшое WPF-приложение под названием CultureInfoExplorer (ссылка на GitHub, бинарники), представленное на вышеприведённом скриншоте. Оно позволяет:

  • По данной CultureInfo посмотреть значение основных её свойств и то, как в ней выглядят некоторые заранее заготовленные строковые паттерны.
  • По данному свойству посмотреть его возможные значения и список всех CultureInfo, которые соответствуют каждому значению.
  • По данному паттерну посмотреть возможные варианты того, во что он может превратиться, и для каждого варианта также посмотреть список соответствующих CultureInfo.

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

Числа

За представление чисел у нас отвечает NumberFormatInfo (доступный через CultureInfo.NumberFormat). И имеются в виду не только обычные числа, а также процентные и денежные значения. Обратите внимание на то, что значения бывают положительные и отрицательные: если вы работаете с локализацией/глобализацией, то важно обращать на это внимание. Настоятельно рекомендую хотя бы пробежаться глазами по документации и посмотреть доступные свойства.

Одно из самых популярных свойств, которое вызывает проблемы у людей, называется NumberDecimalSeparator. Оно отвечает за то, чем будет при форматировании числа отделяться целая часть от дробной. Типичный пример ошибки: программист сливает массив дробных чисел в строчку, разделяя их запятыми. После этого он пытается распарсить строчку обратно в массив. Если NumberDecimalSeparator равен точке, то всё будет хорошо. Скажем, при выставленной культуре en-US у программиста всё заработало, он выпустил свой продукт. Этот продукт скачивает пользователь с культурой ru-RU и начинает грустить: ведь у него NumberDecimalSeparator равен запятой: массив из элементов 1.2 и 3.4 при таком слиянии превратится в строчку "1,2,3,4", а её распарсить будет проблемно. Лично мне становится ещё грустнее тогда, когда встретивший подобную проблему программист не пытается решить её нормально, указывая правильный NumberFormatInfo при форматировании, а начинает колдовать с заменами точек на запятые или запятых на точки. Нужно понимать, что NumberDecimalSeparator, в принципе, может быть любой. Например, в культуре fa-IR (Persian) он равен слешу ('/').

Ещё в нашем распоряжении имеются аналогичные свойства для процентов и валют: PercentDecimalSeparator и CurrencyDecimalSeparator. Все эти три значения вовсе не обязаны совпадать. Например, у казахов (kk-KZ) NumberDecimalSeparator и PercentDecimalSeparator равны запятой, а CurrencyDecimalSeparator равен знаку минус (точно такому же, с помощью которого обозначаются отрицательные числа).

Некоторые считают, что целое число при конвертации в строку даёт значение, состоящее только из цифр. Но цифры эти могут разбиваться на группы. За размер групп отвечает свойство NumberGroupSizes, а за их разделитель — NumberGroupSeparator (аналогичные свойства есть у процентов и валют, но они опять-таки не обязаны совпадать). Группы могут быть разного размера: например, во многих культурах (as-IN, bn-BD, gu-IN, hi-IN и т.п.) NumberGroupSizes равно {3, 2}. Скажем, число 1234567 в культуре as-IN будет выглядеть как "12,34,567". В качестве разделителя групп может выступать пробел \u0020 (например в af-ZA и lt-LT), но, увидев его, не торопитесь вбивать очередной костыль на парсинг и форматирование строк. Чаще всего вместо обычного пробела используется неразрывный пробел \u00A0 (наша родная ru-RU).

Знаки для обозначения отрицательных и положительных чисел также входят в культуру: NegativeSign, PositiveSign. Слава богу, во всех доступных культурах они равны минусу и плюсу, но закладываться на это не стоит: окружение можно переопределить и задать свойствам любые значения. А самое интересное заключается не в знаках, а в паттернах форматирования положительных и отрицательных значений. Например, форматирование отрицательного числа определяется с помощью NumberNegativePattern, у которого есть пять возможных значений:

0 (n)
1 -n
2 - n
3 n-
4 n -

Например, в культуре ti-ET (Tigrinya (Ethiopia)) значение -5 предстанет в виде (5). С процентами и валютами (PercentNegativePattern, PercentPositivePattern, CurrencyNegativePattern, CurrencyPositivePattern) дело обстоит ещё веселее. Например, для CurrencyNegativePattern есть целых шестнадцать возможных значений:

0  ($n)
1  -$n
2  $-n
3  $n-
4  (n$)
5  -n$
6  n-$
7  n$-
8  -n $
9  -$ n
10 n $-
11 $ n-
12 $ -n
13 n- $
14 ($ n)
15 (n $)

Также есть специальные свойства для специальных знаков и специальных численных значений: PercentSymbol, PerMilleSymbol, NaNSymbol, NegativeInfinitySymbol, PositiveInfinitySymbol. Мне доводилось видеть реальный проект, в котором брался double, форматировался в строку (разумеется, в текущей культуре пользователя), а затем в строковом виде сравнивался с «-Infinity». А в зависимости от этой самой текущей культуры NegativeInfinitySymbol может принимать самые разные значения:

'- безкрайност', '-- អនន្ត', '(-) முடிவிலி', '-∞', '-Anfeidredd', '-Anfin', '-begalybė', '-beskonačnost', 'Éigríoch dhiúltach', '-ifedh', '-INF', '-Infini', '-infinit', '-Infinit', '-Infinito', '-Infinitu', '-infinity', 'Infinity-', '-Infinity', 'miinuslõpmatus', 'mínusz végtelen', '-nekonečno', '-neskončnost', '-nieskończoność', '-njekónčne', '-njeskóńcnje', '-onendlech', '-Sonsuz', '-tükeniksizlik', '-unendlich', '-Unendlich', '-Άπειρο', '-бесконачност', 'терс чексиздик', '-უსასრულობა', 'אינסוף שלילי', '-لا نهاية', 'منهای بی نهایت', 'مەنپىي چەكسىزلىك', '-අනන්තය', 'ᠰᠦᠬᠡᠷᠬᠦ ᠬᠢᠵᠠᠭᠠᠷᠭᠦᠢ ᠶᠡᠬᠡ', 'མོ་གྲངས་ཚད་མེད་ཆུང་བ།', 'ߘߊ߲߬ߒߕߊ߲߫-', 'ꀄꊭꌐꀋꉆ', '負無窮大', '负无穷大'

Примеры разных полезных свойств мы разобрали. А теперь давайте немножко пошалим: чуть-чуть изменим русскую культуру, чтобы её новое значение портило нам жизнь в примере из начала поста:

var myCulture = (CultureInfo)new CultureInfo("ru-RU").Clone();
myCulture.NumberFormat.NegativeSign = "!";
myCulture.NumberFormat.PositiveSign = "-";
myCulture.NumberFormat.PositiveInfinitySymbol = "+Inf";
myCulture.NumberFormat.NaNSymbol = "Not a number";
myCulture.NumberFormat.NumberDecimalSeparator = "?";
Thread.CurrentThread.CurrentCulture = myCulture;
Console.WriteLine(-42); // !42
Console.WriteLine(double.NaN); // Not a number
Console.WriteLine(int.Parse("-42")); // 42
Console.WriteLine(1.1); // 1?1

Возможно, кто-то тут скажет мне: «Да зачем такие примеры вообще рассматривать? Ни один программист такое никогда писать не будет!». А я отвечу: «Ну-ну, ни один не будет, как же». Ситуация становится печальной, когда вы распространяете некоторую библиотеку, а один из её пользователей решил поразвлекаться с культурой. Может, он просто любит развлекаться, а может, пишет приложение для какой-то диковиной культуры (скажем, мёртвого или вымышленного языка). Но это не важно. А важно то, что ваша библиотека начинает вести себя странно в непривычном для неё окружении. Поэтому не стоит закладываться на то, что NegativeSign и PositiveSign никогда не меняются. Лучше просто явно указать нужную вам культуру и жить счастливо.

А ещё, всем советую прочитать недавний пост Джона Скита The BobbyTables culture. Краткая суть: Джон Скит ругается на тех, кто не экранирует параметры в SQL-запросах, даже если это числа и даты. И тогда Джон берёт пару запросов

"SELECT * FROM Foo WHERE BarDate > '" + DateTime.Today + "'"
"SELECT * FROM Foo WHERE BarValue = " + (-10)

и определяет чудо-культуру:

CultureInfo bobby = (CultureInfo) CultureInfo.InvariantCulture.Clone();
bobby.DateTimeFormat.ShortDatePattern = @"yyyy-MM-dd'' OR ' '=''";
bobby.DateTimeFormat.LongTimePattern = "";
bobby.NumberFormat.NegativeSign = "1 OR 1=1 OR 1=";

Легким движением руки запросы превращаются в:

SELECT * FROM Foo WHERE BarDate > '2014-08-08' OR ' '=' '
SELECT * FROM Foo WHERE BarValue = 1 OR 1=1 OR 1=10

Ну, думаю, дальнейшие пояснения не нужны.

Дата и время

С датами и временем всё особенно тяжело. За даты у нас отвечает класс DateTimeFormatInfo (свойство CultureInfo.DateTimeFormat), а в нём есть Calendar. Причём есть основной календарь культуры (CultureInfo), а есть список доступных для использования календарей (CultureInfo.OptionalCalendar). В нашем распоряжении имеется большая пачка стандартных календарей:

ChineseLunisolarCalendar, EastAsianLunisolarCalendar, GregorianCalendar, HebrewCalendar, HijriCalendar, JapaneseCalendar, JapaneseLunisolarCalendar, JulianCalendar, KoreanCalendar, KoreanLunisolarCalendar, PersianCalendar, TaiwanCalendar, TaiwanLunisolarCalendar, ThaiBuddhistCalendar, UmAlQuraCalendar (у некоторых есть ряд дополнительных важных параметров). Логика у них, доложу я вам, самая разная. Не будем останавливаться подробно, ибо на эту тему подробной информации в интернете достаточно, а материала хватит на серию самостоятельных постов. Правила форматирования дат и времени ещё более весёлые, чем у чисел: куча паттернов для разных вариантов форматирования даты, нативные имена для месяцев и дней недели, обозначения для AM/PM, разделители и т.п. Скажем, 31 декабря 2014 года может быть представлено (dateTime.ToString("d")) в следующих форматах:

09/03/36
10/3/1436
12/31/2014
1436/3/10
2014.12.21.
2014/12/21
2014-12-21
31. 12. 2014
31.12.14
31.12.14 ý.
31.12.2014
31.12.2014 г.
31.12.2014.
31/12/14
31/12/2014
31/12/2557
31-12-14
31-12-2014
31-дек 14
31-жел-14

И это только дефолтные значения (без подключения опциональных календарей). Но даже тут видно разнообразие летоисчислений: у кого-то на дворе 1436 год, а у кого-то — 2557 (это отсылка к предпоследней строчке примера из начала статьи). Если вы оперируете с датами, то следует задуматься: стоит ли их показывать всегда в одинаковом формате или же подстроиться под пользователя и отобразить дату в более привычном для него виде. Ну, а про парсинг дат я вообще умолчу.

The Turkey Test

Есть классический пост от 2008 года под называнием Does Your Code Pass The Turkey Test?. Подробно пересказывать его не буду, лучше самостоятельно прочитать оригинал. Краткая суть The Turkey Test такова: поменяйте текущую культуру на tr-TR (Turkish (Turkey)) и запустите ваше приложение. Всё ли нормально работает? В этой культуре хватает веселья и с датами, и с числами, и со строками. Если вернуться к нашему первому примеру, то в рассматриваемой культуре "i".ToUpper() не равно "I", а "I".ToLower() не равно "i" (если вам интересно больше узнать про заглавные и строчные буквы, то крайне рекомендую этот пост и вот этот SO-ответ про UTF-8, это просто прекрасно). В конце поста приводится замечательный пример, в котором под регулярное выражение \d{5} подходит строка состоящая из арабских цифр "٤٦٠٣٨".

Вместо заключения

Наука о региональных настройках сложна. В этом посте я ни в коем случае не претендую на то, чтобы выдать полную информацию о том, на что же они могут влиять. Есть ещё очень много разных интересностей, связанных с интернализацией (думаю, только про идущий справа налево текст можно написать отдельный пост, да и не один). Мне просто хотелось показать несколько занимательных примеров того, как CultureInfo.CurrentCulture может повлиять на ваше приложение. Надеюсь, в плане расширения общей эрудиции этот материал окажется кому-то полезным. Общая мораль такова: если вы не хотите думать о том, что в мире существует много разных культур, то используйте везде CultureInfo.InvariantCulture (или другую подходящую вам культуру) — в подавляющем большинстве случаев вы сможете спать спокойно. А если вы об этом задумываетесь, то неплохо бы поизучать эту область более основательно. В этом может помочь вот эта хорошая книжка: Net Internationalization: The Developer's Guide to Building Global Windows and Web Applications.

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

Ссылки

Поделиться:
Исходный код поста находится на GitHub:
https://github.com/AndreyAkinshin/aakinshin.net/blob/master/ru/_posts/dotnet/cultureinfo.md