Когда строка не является строкой?

Original author: Jon Skeet
  • Translation
В рамках моей «работы» над стандартизацией C# 5 в технической группе ECMA-334 TC49-TG2 мне посчастливилось увидеть несколько интересных способов, которыми Владимир Решетников проверял C# на прочность. В данной статье описана одна из проблем, которые он поднял. Разумеется, она, скорее всего, никак не затронет 99.999% C#-разработчиков… но разобраться все равно любопытно.

Спецификации, используемые в статье:


Что такое строка?


Как бы вы объявили тип string (или System.String)? Я могу предположить несколько вариантов ответа на данный вопрос, от расплывчатых до довольно конкретных:

  • «Какой-нибудь текст в кавычках»
  • Последовательность символов
  • Последовательность символов Юникода
  • Последовательность 16-битных символов
  • Последовательность слов UTF-16

Только последнее утверждение полностью верно. Спецификация C# 5 (раздел 1.3) гласит:

Обработка строк и символов в C# использует UTF-16. Тип char представляет слово UTF-16, а тип string – последовательность слов UTF-16.

Пока всё в порядке. Но это C#. А как насчет IL? Что используется там, и имеет ли это значение? Оказывается, что имеет… Строки должны быть объявлены в IL как константы, и природа этого способа представления важна – не только кодировка, но и интерпретация этих закодированных данных. В частности, последовательность слов UTF-16 не всегда может быть представлена в виде последовательности слов UTF-8.

Все очень плохо (сформировано)


Для примера возьмем строковой литерал “X\uD800Y”. Это строковое представление следующих слов UTF-16:

  • 0x0058 – ‘X’
  • 0xD800 – первая часть суррогатной пары
  • 0x0059 – ‘Y’

Это вполне нормальная строка – она даже является строкой Юникода согласно спецификации (раздел D80). Но она плохо сформирована (раздел D84). Это потому, что слову UTF-16 0xD800 не соответствует никакое скалярное значение Юникода (раздел D76) – суррогатные пары явно исключены из списка скалярных значений.

Для тех, кто впервые слышит о суррогатных парах: UTF-16 использует только 16-битные слова, а следовательно не может полностью покрыть все допустимые значения Юникода, диапазон которых равен от U+0000 до U+10FFFF включительно. Если вам нужно представить в UTF-16 символ с кодом больше U+FFFF, то используются два слова: первая часть суррогатной пары (в диапазоне от 0xD800 до 0xDBFF) и вторая (0xDC00 … 0xDFFF). Таким образом, только первая часть суррогатной пары сама по себе не имеет никакого смысла – она является корректным словом UTF-16, но получает значение только если за ней следует вторая часть.

Покажите код!


И как же это всё относится к C#? Ну, константы же надо как-то представлять на уровне IL. Оказывается, способов представления тут два – в большинстве случаев используется UTF-16, но для аргументов конструктора атрибута – UTF-8.

Вот пример:

using System;
using System.ComponentModel;
using System.Text;
using System.Linq;
 
[Description(Value)]
class Test
{
    const string Value = "X\ud800Y";
 
    static void Main()
    {
        var description = (DescriptionAttribute)
            typeof(Test).GetCustomAttributes(typeof(DescriptionAttribute), true)[0];
        DumpString("Атрибут", description.Description);
        DumpString("Константа", Value);
    }
 
    static void DumpString(string name, string text)
    {
        var utf16 = text.Select(c => ((uint) c).ToString("x4"));
        Console.WriteLine("{0}: {1}", name, string.Join(" ", utf16));
    }
}

В .NET вывод данной программы будет следующим:

Атрибут: 0058 fffd fffd 0059
Константа: 0058 d800 0059

Как видите, «константа» осталась в неизменном виде, а вот в значении свойства атрибута появились символы U+FFFD (специальный код, используемый для маркировки битых данных при декодировании бинарных значений в текст). Давайте заглянем еще глубже и посмотрим на IL-код, описывающий атрибут и константу:

.custom instance void [System]System.ComponentModel.DescriptionAttribute::.ctor(string)
= ( 01 00 05 58 ED A0 80 59 00 00 )
.field private static literal string Value
= bytearray (58 00 00 D8 59 00 )

Формат константы (Value) довольно прост – это UTF-16 с порядком байтов от младшего к старшему (little-endian). Формат атрибута же описан в спецификации ECMA-335 в разделе II.23.3. Разберем его подробно:

  • Пролог (01 00)
  • Фиксированные аргументы (для выбранного конструктора)
  • 05 58 ED A0 80 59 (одна упакованная строка)
    • 05 (длина, равная 5 – PackedLen)
    • 58 ED A0 80 59 (значение строки, закодированное в UTF-8)
  • Количество именованных аргументов (00 00)
  • Сами именованные аргументы (их нет)

Самая интересная часть здесь – это «значение строки, закодированное в UTF-8». Значение не является корректной строкой UTF-8, поскольку она плохо сформирована. Компилятор взял первое слово суррогатной пары, определил, что за ней не следует второе, и попросту обработал ее также, как полагается обрабатывать любые другие символы в диапазоне отU+0800 до U+FFFF включительно.

Следует заметить, что если бы у нас была целая суррогатная пара, UTF-8 бы закодировал ее как одно скалярное значение Юникода, использовав 4 байта. Например, поменяем объявление Value на следующее:

const string Value = "X\ud800\udc00Y";

В таком случае на уровне IL мы получим следующий набор байтов: 58 F0 90 80 80 59 – где F0 90 80 80 – это представление слов UTF8 под номером U+10000. Эта строка сформирована корректно и ее значения в атрибуте и константе были бы одинаковыми.

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

Поведение кодировки


Так какой же подход правильный? Согласно спецификации Юникода (раздел C10), оба верны:

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

И в то же время:

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

Мне не до конца ясно, должны ли значения констант и аргументов атрибутов «представлять собой закодированные символы Юникода». По моему опыту, в спецификации практически нигде не указано, требуется ли корректно сформированная строка или это не обязательно.

Кроме того, реализации System.Text.Encoding можно настроить, указав поведение в случае попытки кодирования или декодирования плохо сформированных данных. Например:

Encoding.UTF8.GetBytes(Value)

Вернет последовательность байтов 58 EF BF BD 59 – иными словами, обнаружит некорректные данные и заменит из на U+FFFD, и декодирование пройдет без проблем. Однако:

new UTF8Encoding(true, true).GetBytes(Value)

Выбросит исключение. Первый аргумент конструктора указывает на необходимость генерировать BOM, второй – на то, как поступать с некорректными данными (также используются свойства EncoderFallback и DecoderFallback).

Поведение языка


Так должен ли этот код вообще компилироваться? На данный момент спецификация языка этого не запрещает – но спецификацию можно поправить :)

Вообще говоря, и csc, и Roslyn все-таки запрещают использование плохо сформированных строк в некоторых атрибутах, например DllImportAttribute:

[DllImport(Value)]
static extern void Foo();

Этот код выдаст ошибку компилятора, если значение Value плохо сформировано:

error CS0591: Invalid value for argument to 'DllImport' attribute

Возможно, есть и другие атрибуты с таким же поведением – не уверен.

Если считать, что значение аргумента атрибута не будет декодировано в изначальную форму при создании экземпляра атрибута – это можно с чистой совестью считать ошибкой на этапе компиляции. (Если, конечно, мы не меняем среду выполнения так, чтобы она сохраняла в точности значение плохо сформированной строки)

А вот что делать с константой? Должно ли это быть допустимым? Может ли в этом быть смысл? В том виде, в котором строка использована в примере – вряд ли, но может быть случай, когда строка должна кончаться первой частью суррогатной пары, чтобы потом сложить ее с другой строкой, начинающейся со второй части, и получить корректную строку. Разумеется, тут нужно проявлять крайнюю осторожность – в Техническом Отчете Юникода #36 (Соображения безопасности) представлены весьма настораживающие возможности возникновения ошибок.

Следствия из вышесказанного


Один из интересных аспектов всего этого заключается в том, что «арифметика кодирования строк» может работать не так, как вам кажется:

// Плохой код!
string SplitEncodeDecodeAndRecombine(string input, int splitPoint, Encoding encoding)
{
    byte[] firstPart = encoding.GetBytes(input.Substring(0, splitPoint));
    byte[] secondPart = encoding.GetBytes(input.Substring(splitPoint));
    return encoding.GetString(firstPart) + encoding.GetString(secondPart);            
}

Вам может показаться, что здесь не может быть ошибок, если нигде нет null, а значение splitPoint входит в диапазон. Однако если вы попадете посреди суррогатной пары, всё будет очень грустно. Тут могут также возникнуть дополнительные проблемы из-за вещей наподобие формы нормализации – скорее всего, конечно, нет, но к этому моменту я уже ни в чем не уверен на сто процентов.

Если вам кажется, что этот пример оторван от реальности, то представьте себе большой кусок текста, разделенный на несколько сетевых пакетов, или файлов – не важно. Вам может показаться, что вы достаточно предусмотрительны и заботитесь о том, чтобы бинарные данные не поделились посреди кодовой пары UTF-16 – но даже это вас не спасет. Ой-ой.

Меня прямо-таки порывает отказаться от обработки текстов вообще. Числа с плавающей запятой – сущий кошмар, даты и время… ну, вы знаете, что я про них думаю. Интересно, есть ли какие-нибудь проекты, в которых используются только целые числа, которые гарантированно никогда не переполняются? Если у вас есть такой проект – дайте знать!

Заключение


Текст – это трудно!

Примечание переводчика:
Ссылку на оригинал данной статьи нашел в посте «Поговорим про отличия Mono от MS.NET». Спасибо DreamWalker! У него в блоге, кстати говоря, также есть небольшая продолжающая тему заметка о том, как ведет себя этот же пример под Mono.

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 18

    +1
    MS не планируют UTF-8 везде в .Net начать использовать?
      0
      А зачем это это нужно делать?
        +1
        Уверен, везде останется UTF-16. Меня удивляет, что в IL для каких-то целей используется UTF-8.
        +3
        И как же это всё относится к C#? Ну, константы же надо как-то представлять на уровне IL. Оказывается, способов представления тут два – в большинстве случаев используется UTF-16, но для аргументов конструктора атрибута – UTF-8.
        Кто-нибудь знает, почему для аргументов конструктора атрибута используется UTF-8 вместо UTF-16?
          0
          Возможно я не прав, но логичным объяснением, является то что, в конструкторах атрибутов используются только константы.
          Логичным продолжением вопроса является вопрос, а почему в конструкторе атрибутов используются константы? Ну, думаю дело в том, что значения атрибутов попадают в метаданные, а они формируются на этапе компиляции.
            0
            Я не очень понимаю, как из того, что данные атрибутов хранятся в метаданных сборки следует то, что строковые метаданные должны хранится в формате UTF-8 вместо UTF-16.
              0
              Полагаю, что для хранение метаданных, как и в il, используется UTF-8.
                0
                Похоже, что это сделано в целях оптимизации. Если посмотреть в спецификацию MSIL, а именно, на пункты «II.24.2.3 #Strings heap» и «II.24.2.4 #US and #Blob heaps», то становится ясно, что сборка хранит служебную информацию в UTF-8 формате(кстати, это отностися не только к пользовательским атрибутам, но и к версии самой сборки) в специальном блоке #Strings, а пользовательские строки в формате UTF-16 в другом специальном блоке #US(похоже это акроним от user strings).

                Выглядит, как конфликт спецификаций C# и MSIL.
          +1
          По части сети проблем нет. Все-таки по сети передаются байты, а не строки. Строки получаются лишь после интерпретации этих данных программой. Они лишь частный случай. С тем же успехом может прилететь половина байтового представления Int. Порядок интерпретации всегда на совести программы.
            0
            Каким образом input.Substring(splitPoint) может попасть посреди суррогатной пары, ведь splitPoint указан не в байтах, а в символах?

            По-моему для воспроизведения проблемы тут надо сначала делать GetBytes, а потом полученный кусок байт разрывать посередине.
              +1
              Легко это может попасть. Суррогатная пара — это два отдельных UTF-16 символа. Соответственно на уровне работы со строками нет никакой инфы о том, это суррогатная пара тут у нас или два отдельных символа.
                0
                Кстати, наверное, существует возможность определить свои методы работы со строками, которые рассматривают суррогатные пары как один символ.
                  0
                  Я далек от C#, но думаю такая возможность есть. Однако в этой ситуации мы будем вынужденны при каждом изменении строки парсить ее, чтобы понять есть ли в ней суррогатные пары или нет, что негативно скажется на скорости.
                  +1
                  Суррогатная пара — это два отдельных UTF-16 символа.

                  Что-то тут не так. Выше по тексту сказано: «Если вам нужно представить в UTF-16 символ с кодом больше U+FFFF, то используются два слова: первая часть суррогатной пары (в диапазоне от 0xD800 до 0xDBFF) и вторая (0xDC00 … 0xDFFF)». То есть для представления одного символа используются два слова суррогатной пары. Одного символа же, а не двух.
                    +1
                    Обычный символ UTF-16 — это два байта.
                    Суррогатная пара — это 4 байта. Два «обычных» символа.
                      +1
                      Спрошу иначе — на каких конкретно данных приведённый выше «плохой код» выдаст неправильный результат?

                      Сколько байт в суррогатной паре — понятно из статьи. Непонятно то, сколько символов представляет такая суррогатная пара. Если один — тогда Substring() не должен его разбивать на два. Если два — тогда зачем называть их «суррогатной парой», почему просто не считать их двумя разными символами.

                      Насколько я понял из объяснений — первый элемент суррогатной пары не может быть интерпретирован как самостоятельный символ. То есть, он имеет логический смысл только как составляющая пары, стало быть пара представляет собой один символ, а не два, и соответственно должна интерпретироваться как один символ библиотечными методами работы со строками. Но это, конечно, если строка правильно сформирована, то есть содержит в себе валидное «символьное» значение.
                        +1
                        «Должна быть» — сентенция для идеального мира.
                        А мы живем в мире сложном. И если делать методы работы со строками идеальными, то мы потеряем возможность работать со строкой как с массивом, а значит имеем проседание производительности там где не должны. Терять производительность ради 0.00001% программистов, которым реально нужно работать с сложным unicode — бессмысленное занятие
                        UCS-2 хватит всем. А кому не хватает — должны думать прежде чем использовать стандартные методы работы со строками.
                          +2
                          В общем, я тут наконец погулил и вразумился. Оказалось, что если строка содержит суррогатные пары, то не только Substring, но и другие методы работы со строками будут давать странные результаты, поскольку работают именно с последовательностью 16-битных char, а не грубо говоря с «буквами» (не с логическим значением этих char'ов), то есть фактически не берут в расчёт кодировку. Получается, что для них суррогатрая пара — это действительно два разных символа, хотя для UTF-16 это один символ.

                          Таким образом, если некая «буква» представлена двумя char'ами, и я пишу слово из трёх таких букв, Length скажет, что длина такой строки — шесть символов. А не три. Так же себя ведут Substring, IndexOf и т.п. Собственно, об этом и статья — что строка C# это не то же самое что строка в кодировке UTF-16.

                          Спасибо, теперь вроде понятно))

              Only users with full accounts can post comments. Log in, please.