Ненормальное программирование
Программирование
.NET
18 ноября 2014

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

Автор оригинала: Jon Skeet
Перевод
В рамках моей «работы» над стандартизацией 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.

+27
18,5k 112
Комментарии 18