[Перевод] Когда идентификатор не идентификатор (Атака монгольского разделителя гласных)
Примечания переводчика В переводе я позволил себе использовать некоторые англицизмы, такие как «валидный», «нативный» и «бинарник». Надеюсь с ними вопросов не возникнет.Идентификаторы (identifiers) — специальный термин спецификации C# отожествляющий собой всё к чему можно обратиться по имени, как например название класса, имя переменной и т.д.
Roslyn — компилятор C# кода, написанный на C#. Был создан взамен существующего csc.exe. Я обычно опускаю слово компилятор в данном тексте.
Для начала несколько вещей о которых вы могли не слышать: Идентификаторы в C# могут включать в себя escape-последовательности Unicode символов (как например \u1234). Идентификаторы в C# могут включать в себя Unicode символы категории Cf (other, format), но при сравнении идентификаторов на идентичность эти символы игнорируются. Символ «Монгольский разделитель гласных» (U+180E) в зависимости от версии Unicode принадлежит либо категории Cf (other, format), либо категории Zs (separator, space). В .NET хранится свой собственный список Unicode категорий, независимый от оных в Win32. Roslyn является .NET приложением, и поэтому использует Unicode категории, прописанные в файлах .NET. Нативный компилятор (csc.exe) использует либо системные (Win32) категории, либо хранит в себе копию таблиц Unicode. Никакая из таблиц Unicode символов (ни .NET, ни Win32) точно следует какой-либо из версий стандарта Unicode. Компиляторы могут иметь баги. Из всего этого вытекают некоторые проблемы… Все началось с обсуждения на собрании технической группы ECMA на прошлой неделе. Мы рассматривали «нормативные ссылки», и в частности какую версию стандарта Unicode мы будем использовать. На тот момент спецификация ECMA-335 (4-ое издание) использует Unicode 4.0, а спецификация C# 5 от Microsoft использует Unicode 3.0. Я точно не знаю, учитывают ли разработчики компиляторов такие особенности. На мой взгляд было бы лучше, если ECMA и Microsoft не указывали конкретную версию Unicode в своих спецификациях. Пусть разработчики компиляторов используют самую свежую версию Unicode, доступную на текущий момент. Однако тогда компиляторы должны будут поставляться со своей личной копией таблицы Unicode, что немного странно, на мой взгляд.Во время нашего обсуждения Владимир Решетников вскользь упомянул «монгольский разделитель гласных» (U+180E), которого изрядного помучила жизнь. Этот символ был добавлен в Unicode 3.0.0 в категорию Cf (other, format). Затем, в Unicode 4.0.0 его переместили в категорию Zs (separator, space), а в Unicode 6.3.0 его вернули опять в категорию Cf.Разумеется, я пытался осудить такие действия. Моей первоначальной целью являлось показать вам код, который вел бы себя по-разному, в зависимости от версии таблицы Unicode, которую использует компилятор. Однако выяснилось, что на самом деле все немного сложнее. Но для начала мы предположим, что используем «гипотетический компилятор», которые не содержит багов, и использует любую версию Unicode, какую мы пожелаем (что является багом согласно требованиям текущей спецификации C#, но мы оставим в стороне такую тонкость).
Для простоты, на время забудем о всяких UTF, и воспользуемся обычным ASCII: class MvsTest{ static void Main () { string stringx = «a»; string\u180ex = «b»; Console.WriteLine (stringx); }}
В случае если компилятор использует Unicode версии 6.3 или выше (или версию ниже чем 4.0), то U+180E будет считаться символом из категории Cf, и, следовательно, разрешенным для использования в идентификаторе. Если символ разрешено использовать в идентификаторе, то вместо этого символа мы можем использовать escape-последовательность, и компилятор с радостью обработает его корректно. Идентификатор во второй строке этого метода считается «идентичным» stringx, так что на экран будет выведено «b».
Так что насчет компилятора, который использует Unicode версии 4.0 — 6.2 включительно? В этом случае, U+180E будет считаться символом из категории Zs, что делает его пробельным символом. Пробельные символы разрешены внутри C# кода, но не в самих идентификаторах. А так как этот символ не является разрешенным идентификатором и не находится внутри символьного\строкового литерала, то с точки зрения компилятора использование escape-последовательности в данном участке неправильно, и поэтому данный участок кода просто не скомпилируется.
Однако мы можем написать тот же самый код без использования escape-последовательности. Для этого надо создать обычный ASCII файл: class MvsTest{ static void Main () { string stringx = «a»; stringAAAx = «b»; Console.WriteLine (stringx); }}
Затем открыть его в hex-редакторе и заменить символы AAA байтами E1 A0 8E. Таким образом мы получили файл, содержащий UTF-8 представление символа U+180E в том же самом месте, в котором оно было отображено с помощью escape-последовательности в первом примере.
Компилятор, который успешно принял первый пример, будет также компилировать и этот вариант (предполагая, что вы смогли указать компилятору, что файл закодирован в UTF-8), и результат будет точно таким же — на экран будет выведено «b», так как вторая конструкция в методе является простым присваиванием к существующей переменной.
Однако, даже если компилятор воспринимает U+180E как пробельный символ (то есть откажется компилировать программу из примера 1), проблем с этим вариантом все равно не возникнет, компилятор примет второе выражение в методе как объявление новой локальной переменной x и присваивание ему какого-то начального значения. Вы можете получить предупреждение компилятора об объявлении неиспользуемой локальной переменной, но код будет успешно скомпилирован и на экран будет выведено «a».
Когда мы говорим о Microsoft C# компиляторе, нам надо различать нативный компилятор (csc.exe) и Roslyn (rcsc, хотя я обычно зову его просто Roslyn).Так как csc.exe написан на нативном коде, он использует либо встроенные в Windows средства для работы с Unicode, либо просто хранит в своем исполняемом файле таблицу Unicode символов. (Я облазил весь MSDN в поисках нативной Win32 функции для определения принадлежности символа к определенной Unicode категории, но так и ничего не нашел. А жаль, такая функция была бы очень полезна…)
В это время Roslyn, который написан на C# и для определения Unicode категории (насколько я знаю) использует char.GetUnicodeCategory (), который полагается на встроенные в mscorlib.dll Unicode таблицы.
Мои эксперименты наводят на мысль, что вне зависимости от того что нативный компилятор использует для определения категории, U+180E всегда принимается за символ Cf категории. По крайней мере я пытался найти старые машины (включая VM образы), на которые не были установлены какие-либо обновления начиная с Сентября 2013 года (именно в этом время был опубликован стандарт Unicode 6.3) и все они компилировали программу из первого примера без каких-либо ошибок. Я начинаю подозревать, что csc.exe вероятно имеет встроенную в бинарник копию таблицы Unicode 3.0. Он определенно воспринимает U+180E как символ форматирования, но «не любит» символы U+0600 и U+00AD в идентификаторах (U+0600 не был представлен до появления Unicode 4.0, но он всегда являлся символом форматирования; U+00AD в Unicode 3.0 являлся пунктуационным символом (тире), но начиная с Unicode 4.0 он является символом форматирования)
Однако таблица, встроенная в mscorlib.dll определенно менялась с появлением новых версий .NET Framework. Если вы запустите такую программу:
using System;
class Test{ static void Main () { Console.WriteLine (Environment.Version); Console.WriteLine (char.GetUnicodeCategory ('\u180e')); }}
То под CLRv2 на экран будет выведено «SpaceSeparator», в то время как под CLRv4 (по крайней мере на недавно обновленной системе) будет выведено «Format».
Разумеется, Roslyn не будет работать на старых версиях CLR. Однако у нас все же есть надежда в лице csharppad.com, которая запускает Roslyn в какой-то среде (неизвестного происхождения, может Mono? не уверен в этом), и, в результате, на экран выводится «SpaceSeparator». Я уверен, что программа из первого примера не будет скомпилирована. Однако со вторым примером все сложнее — csharppad.com не позволяет загрузить файл исходного кода, а copy/paste даёт странный результат.
Компилятор Mono использует также использует метод GetUnicodeCategory (), что делает наши эксперименты намного проще, но, к сожалению, парсер Mono имеет, по крайней мере, 2 бага: Он позволяет использовать любую escape-последовательность в качестве идентификатора, вне зависимости от того является ли эта escape-последовательность валидным идентификатором или нет. К примеру, с точки зрения компилятора Mono конструкция string\u0020x = » валидна. Отмечено как баг 24968. Источник. Он не позволяет использовать символы форматирования внутри идентификаторах, включая символы из категории Mn, Mc, Nd и Pc, но не Cf. Отмечено как баг 24969. Источник. По этой причине программа из первого примера всегда компилируется, и выводит на экран «b». Однако программа из второго пример будет выдавать ошибку компиляции, вне зависимости от того к какой из категорий (Zs или Cf) по мнению компилятора относится символ U+180E. Далее, давайте поразмышляем о самой таблице Unicode в .NET, так как не совсем понятно какую версию Unicode используют различные реализаций BCL. Запустим такую программу: using System;
class Test{ static void Main () { Console.WriteLine (char.GetUnicodeCategory ('\u00ad')); Console.WriteLine (char.GetUnicodeCategory ('\u0600')); Console.WriteLine (char.GetUnicodeCategory ('\u180e')); }}
На моем компьютере данная программа, запущенная под CLRv4, выдает «DashPunctuation, Format, Format», а под Mono (3.3.0) и CLRv2 выдает «DashPunctuation, Format, SpaceSeparator».
Это как минимум странно. Такое поведение не соответствует ни одной из версий стандарта Unicode, насколько я могу утверждать.
U+00AD являлся Po (other, punctuation) символом в Unicode 1.x, затем Pd (dash, punctuation) в 2.x и 3.x, и начиная с 4.0 является Cf символом. U+0600 был впервые представлен в Unicode 4.0 и всегда являлся Cf символом. U+180E был представлен в качестве Cf символа в Unicode 3.0, затем стал Zs символом в Unicode 4.0, и наконец вернулся опять в категорию Cf в Unicode 6.3. Таким образом, ни одна из версий стандарта Unicode не соответствует первой или третьей строке вывода. Теперь я по-настоящему сбит с толку… Идентификаторы используются не только для сравнения, они доступны в качестве строк (C# строк) без какого-либо использования Reflection. Начиная с C# 5, нам доступен атрибут CallerMemberName, позволяющий нам делать такие вещи: public static void X\u0600y (){ ShowCaller ();} public static void ShowCaller ([CallerMemberName] string caller = null){ Console.WriteLine («Called by {0}», caller);}
А в C# 6 мы можем написать так:
string x\u0600y = »; Console.WriteLine («nameof = {0}», nameof (x\u0600y));
Что эти две программы выведут на экран? Они просто выведут «Xy» и «xy» в качестве имен, как если бы компилятор просто выкинул все символы форматирования. Но что они должны вывести? Надо принять во внимание, что во втором случае мы могли бы просто написать nameof (xy) и такая строка все равно оставалось бы равной строке объявленного идентификатора.
Мы даже не можем сказать: «Какое имя у объявленного члена?», потому что вы можете перегрузить его «другим, но равным ему» идентификатором:
public static void Xy () {}public static void X\u0600y () {}public static void X\u070fy () {}…Console.WriteLine (nameof (X\u200by));
Что должно быть выведено на экране? Я уверен, вы почувствуете облегчение, узнав, что у создателей C# есть план на этот счет, но это действительно один из тех сценариев, для которых «нет очевидного правильного ответа». Все становится еще более странным, когда в дело вступает спецификация CLI. Секция I.8.5.1 стандарта ECMA-335 6-го издания говорит:
Сборки должны руководствоваться Приложению 7 Технического Отчета 15 Стандарта Юникод 3.0 определяющий набор символов, разрешенных к использованию в идентификаторах, который (прим. перев: набор символов) доступен на www.unicode.org/unicode/reports/tr15/tr15–18.html. Идентификаторы должны быть в каноничном формате, определённом в «Форма Нормализации С Юникода». Для удовлетворения спецификации CLS, два идентификатора должны быть одинаковы, только если их представления в нижнем регистре (определенные Юникод locale-независимым отображением один-к-одному в нижнем регистре) одинаковы. По этой причине, чтобы два идентификатора рассматривались как различные, согласно CLS, они должны различаться сильнее, чем просто в регистре символов. Однако, для того чтобы переопределить унаследованное определение, CLI требует точную кодировку, использованную для кодирования исходного определения.
Я хотел бы изучить влияние этого документа добавлением Cf символа в IL, но, к сожалению, я до сих пор не смог выяснить способ повлиять на кодировку, используемую ilasm, для того, чтобы убедить его, что мой «подкорректированный» IL является тем, чем я хочу, чтобы он был. Как было упомянуто ранее, текст сложен.Выяснилось, что даже ограничив себя только идентификаторами, «текст сложен». Кто бы мог подумать?
От переводчика: благодарю пользователя impwx за перевод предыдущей публикации Джона Скита