О литералах, регулярных выражениях и пользе документации

Один из моих коллег недавно наступил на грабли, словив глюк с неочевидными на первый взгляд симптомами, которые и сформулировать-то затруднительно. В общем, дело было так: сидит он себе, никого не трогает, программу пишет. Конкретно — фильтр, пропускающий лишь символы из определенного диапазона. Достаточно широкого, кстати: по сути, требовались отсечь управляющие символы, за исключением некоторых (отдельно хочу отметить сам подход: отсечь всё, кроме нужного, а не отсечь ненужное — это правильно; почему-то разница очевидна не для всех). Так вот. Коллега столкнулся с прелюбопытным явлением: регулярное выражение, заданное диапазоном, одну его (диапазона) часть пропускало, а другую — отфильтровывало. Минимальный код:

static void Main(string[] args)
{
    string src = "абв abc 123";
    Regex rx = new Regex(@"[^x09x0Ax0Dx0020-xD7FFxE000-xFFFD]");
    Console.WriteLine(rx.Replace(src, String.Empty));
    Console.ReadKey();
}

Результат: abc123
Хмм. Странно, правда? Латиница осталась, кириллица ушла (и пробелы с собой забрала), хотя и то, и другое, вроде бы, в одном и том же диапазоне. Как так? Что за ерунда? Примерно это я услышал с соседнего стола.
Коллеге я почти сразу посоветовал заменить x на u, и у него все заработало подобающим образом. Но вопиющая нелогичность произошедшего задела меня за живое. Я стал экспериментировать, и запутался еще больше. Взгляните-ка:

static void Main(string[] args)
{
    string src = "абв abc 123";
    Console.WriteLine(Regex.Replace(src, @"[^x09x0Ax0Dx0020-xD7FFxE000-xFFFD]", String.Empty));
    Console.WriteLine(Regex.Replace(src, @"[^x09x0Ax0Du0020-uD7FFxE000-xFFFD]", String.Empty));
    Console.WriteLine(Regex.Replace(src, "[^x09x0Ax0Dx0020-xD7FFxE000-xFFFD]", String.Empty));
    Console.WriteLine(Regex.Replace(src, "[^x09x0Ax0Du0020-uD7FFxE000-xFFFD]", String.Empty));
    Console.ReadKey();
}
abc123
абв abc 123
абв abc 123
абв abc 123

То есть, загадочные симптомы проявляются при сочетании двух факторов: буквальности литерала и шестнадцатиричных escape-последовательностях вместо unicode-последовательностей. Попробуем разобраться, почему. Для этого воспользуемся дизассемблером IL и посмотрим, на что похож предыдущий исходник под капотом:

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       102 (0x66)
  .maxstack  3
  .locals init ([0] string src)
  IL_0000:  nop
  IL_0001:  ldstr      bytearray (30 04 31 04 32 04 20 00 61 00 62 00 63 00 20 00   // 0.1.2. .a.b.c. .
                                  31 00 32 00 33 00 )                               // 1.2.3.
  IL_0006:  stloc.0
  IL_0007:  ldloc.0
  IL_0008:  ldstr      "[^\x09\x0A\x0D\x0020-\xD7FF\xE000-\xFFFD]"
  IL_000d:  ldsfld     string [mscorlib]System.String::Empty
  IL_0012:  call       string [System]System.Text.RegularExpressions.Regex::Replace(string,
                                                                                    string,
                                                                                    string)
  IL_0017:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_001c:  nop
  IL_001d:  ldloc.0
  IL_001e:  ldstr      "[^\x09\x0A\x0D\u0020-\uD7FF\xE000-\xFFFD]"
  IL_0023:  ldsfld     string [mscorlib]System.String::Empty
  IL_0028:  call       string [System]System.Text.RegularExpressions.Regex::Replace(string,
                                                                                    string,
                                                                                    string)
  IL_002d:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0032:  nop
  IL_0033:  ldloc.0
  IL_0034:  ldstr      bytearray (5B 00 5E 00 09 00 0A 00 0D 00 20 00 2D 00 FF D7   // [.^....... .-...
                                  00 E0 2D 00 FD FF 5D 00 )                         // ..-...].
  IL_0039:  ldsfld     string [mscorlib]System.String::Empty
  IL_003e:  call       string [System]System.Text.RegularExpressions.Regex::Replace(string,
                                                                                    string,
                                                                                    string)
  IL_0043:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0048:  nop
  IL_0049:  ldloc.0
  IL_004a:  ldstr      bytearray (5B 00 5E 00 09 00 0A 00 0D 00 20 00 2D 00 FF D7   // [.^....... .-...
                                  00 E0 2D 00 FD FF 5D 00 )                         // ..-...].
  IL_004f:  ldsfld     string [mscorlib]System.String::Empty
  IL_0054:  call       string [System]System.Text.RegularExpressions.Regex::Replace(string,
                                                                                    string,
                                                                                    string)
  IL_0059:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_005e:  nop
  IL_005f:  call       valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey()
  IL_0064:  pop
  IL_0065:  ret
} // end of method Program::Main

Ага. Компилятор преобразовал x- и u-последовательности в символы в обычных литералах, а буквальные оставил как есть (ну, логично: потому-то они и буквальные). Похоже, разгадка нашей загадки кроется глубже, в механизме сопоставлений регулярных выражений: символы всегда обрабатываются правильно (это объясняет, почему с небуквальными литералами всё в порядке), а вот именно с x-последовательностями возникают проблемы (хотя с u-последовательностями их нет). Что ж, теперь, когда мы окончательно запутались, самое время почитать документацию на предмет x.
Два символа, вот как. А у нас четыре. Вооружённые этим знанием, мы можем теперь понять, как на самом деле выглядит наше регулярное выражение с точки зрения их интерпретатора: [^x09x0Ax0Dx0020-xD7FFxE000-xFFFD]
Символы от 0 до ×, плюс символы от 0 до ÿ, плюс ещё парочка, плюс x00 (потенциально опасный, кстати, при общении с некоторыми неуправляемыми средами)? Это всё объясняет. Действительно, все цифры, латинские буквы и часть западноевропейских символов в пределах этих диапазов (и потому фильтр пропускает их), а кириллица и почти всё прочее — вне их, и поэтому фильтр удаляет эти символы.
Почему не было разницы в работе буквальных и небуквальных литералов в случае u? Потому что компилятор C# и интерпретатор регулярных выражений понимают u-последовательности одинаково.
Почему есть разница в работе буквальных и небуквальных литералов в случае x? Потому что компилятор понимает их одним образом (позволяя как однобайтовые, так и двухбайтовые символы), а интерпретатор шаблонов регулярных выражений — другим (позволяя только однобайтовые).
Крайне странно натыкаться на что-то подобное в шарпе — языке, который выглядит максимально последовательным и предсказуемым. Что ж, с каждым может случиться.
А документацию по используемым фичам всё равно стоит читать. Особенно когда сталкиваешься с неожиданным поведением.

5 thoughts on “О литералах, регулярных выражениях и пользе документации

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s