Коли ідентифікатор не ідентифікатор (Атака монгольського роздільника голосних)


Примітки перекладачаУ перекладі я дозволив собі використати деякі англіцизми, такі як «дійсний», «нативний» і «бінарники». Сподіваюсь з ними питань не виникне.

Ідентифікатори (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#, але ми залишимо осторонь таку тонкість).

Гіпотетичний приклад 1: правильний чи неправильний
Для простоти, на час забудемо про всяких 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-послідовності в цій ділянці неправильно, і тому цю ділянку коду просто не відбудеться створення.

Гіпотетичний приклад 2: правильний, двома різними способами
Однак ми можемо написати той самий код без використання 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
Коли ми говоримо про 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 дає дивний результат.

Реальність: mcs (Mono C# компілятор)
Компілятор 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 не відповідає першої або третьої рядку виводу. Тепер я по-справжньому збитий з пантелику…

щодо nameof і CallerMemberName?
Ідентифікатори використовуються не тільки для порівняння, вони доступні в якості рядків (C# рядків) без будь-якого використання Reflection. Починаючи з C# 5, нам доступний атрибут CallerMemberName, що дозволяє нам робити такі речі:

public static void X\u0600y()
{
    ShowCaller();
}
 
public static void ShowCaller([CallerMemberName] string caller = null)
{
    Console.WriteLine("Called на {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 за переклад попередньої публікації Джона Скиту

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.