Вся правда о UTF-8 флаге

20404455ac40f027b0b0fcd46c7fdea1

Распространённое заблуждение состоит в том, что строки символов, в отличие от строк байтов, имеют UTF-8 флаг установленным.
Многие догадываются, что если данные являются ASCII-7-bit, то UTF-8 флаг просто не важен.

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

Широко известный в Perl комьюнити автор Marc Lehmann делает об этом замечание в документации к модулю JSON::XS

You can have Unicode strings with that flag set, with that flag clear, and you can have binary data with that flag set and that flag clear. Other possibilities exist, too.

Рассмотрим случай, когда ASCII-7bit данные имеют UTF-8 флаг установленным.
use utf8;
use strict;
use warnings;
my $u = "тест"; # unicode строка
my $ascii = "x"; # обычный ASCII символ
my ($ascii_u, undef) = split(/ /, "$ascii $u");
die unless $ascii_u eq "x"; # тот же ASCII символ
print "UTF-8 flag set!" if utf8::is_utf8($ascii_u); # но теперь у него установлен UTF-8 флаг

 

Этот код выводит «UTF-8 flag set!». То есть ASCII-7bit строка получила этот флаг, после того, как операция split разделила Unicode строку (с UTF-8 флагом) на части. Можно сказать, что программист не контролирует будет ли у его ASCII данных UTF-8 флаг или нет, это зависит от того, откуда и как получены данные, и от того, какие данные были рядом с ними.

Тот же эффект получается, если декодировать ASCII-7bit байты в ASCII-7bit символы с помощью Encode::decode()

use strict;
use warnings;
use Encode;
my $ascii = 'x'; # ASCII символ
my $ascii_u = decode("UTF-8", encode("UTF-8", "$ascii"));
die unless $ascii_u eq "x"; # тот же ASCII символ
print "UTF-8 flag set!" if utf8::is_utf8($ascii_u); # но теперь у него установлен UTF-8 флаг

 

Т.е. перекодировка туда-обратно не меняет данные (это ожидаемо), но устанавливает UTF-8 флаг.
(впрочем, такое поведение decode() противоречит его собственной документации, которая, в свою очередь, противоречит идее, что никакой документации и гарантий относительно utf-8 флага в ASCII данных быть не должно)

Объяснить же причины появление UTF-8 флага можно соображениями эффективности. Слишком накладно после split анализировать строку, чтобы понять, состоит ли она только из ASCII символов, и можно ли сбросить флаг.

Такое поведение UTF-8 флага похоже на вирус — он заражает все данные с которыми соприкасается.

Рассмотрим случай, когда не-ASCII, Unicode символы не имеют UTF-8 флага.
use strict;
use warnings;
use Digest::SHA qw/sha1_hex/;
use utf8;
my $s = "µ";
my $s1 = $s;
my $s2 = $s;

my $digest = sha1_hex($s2); # попробуйте закомментировать эту строчку

print "utf-8 bit ON (s1)\n" if utf8::is_utf8($s1);
print "utf-8 bit ON (s2)\n" if utf8::is_utf8($s2);

print "s1 and s2 are equal\n" if $s1 eq $s2;

 

печатает:

utf-8 bit ON (s1)
s1 and s2 are equal

То есть вызов функции стороннего модуля сбросил UTF-8 флаг. При этом, строки с флагом и без, оказались полностью идентичными.
Такое может случится только с символами > 127 и <=255 (т.е. Latin-1).

На самом деле, со строкой $s2 произошла операция utf8::downgrade

Эта функция описана в документации, как меняющая внутреннее представление строки:

Converts in-place the internal representation of the string from UTF-X to the equivalent octet sequence in the native encoding (Latin-1 or EBCDIC). The logical character sequence itself is unchanged.

В принципе модуль Digest::SHA документирует такое своё поведение, хотя не обязан:

Be aware that the digest routines silently convert UTF-8 input into its
equivalent byte sequence in the native encoding (cf. utf8::downgrade). This
side effect influences only the way Perl stores the data internally, but
otherwise leaves the actual value of the data intact.

В общем случае любая 3-rd party функция может сделать downgrade строки, не сообщая в этом в документации (или, например, делать его только иногда).

Рассмотрим случай, когда абсолютно произвольные, бинарные данные имеют UTF-8 флаг.
use utf8;
use strict;
use warnings;

# нам нужен bytes::length для отладки, ставим '()' чтобы bytes не влияло на ход программы
use bytes ();

my $u = "тест"; # не ASCII строка

# байты, не символы
my $bin = "\xf1\xf2\xf3";

## опять получает ASCII строку с UTF-8 флагом
my $ascii = "x"; # обычный ASCII симовл
my ($ascii_u, undef) = split(/ /, "$ascii $u");
die unless $ascii_u eq "x"; # тот же ASCII символ
die unless utf8::is_utf8($ascii_u); # но теперь у него установлен UTF-8 флаг
## //

print "original bin length:\t";
print length($bin) . "\t" . bytes::length($bin) ."\n";

my $bin_a = $bin.$ascii; # соединяем бинарные данные, с ASCII данными

print "bin_a length:\t";
print length($bin_a) . "\t" . bytes::length($bin_a) ."\n";

my $bin_u = $bin.$ascii_u; # опять соединяем бинарные данные, с ASCII данными

print "bin_u length:\t";
print length($bin_u) . "\t" . bytes::length($bin_u) ."\n";

print "bin_a and bin_u are equal!\n" if $bin_a eq $bin_u;

open my $f, ">", "file_a.tmp";
binmode $f;
print $f $bin_a;
close $f;

open $f, ">", "file_u.tmp";
binmode $f;
print $f $bin_u;
close $f;

system("md5sum file_?.tmp"); # md5sum - команда linux

 

выдаёт:

original bin length:    3       3
bin_a length:   4       4
bin_u length:   4       7
bin_a and bin_u are equal!
33818f4b23aa74cddb8eb625845a459a  file_a.tmp
33818f4b23aa74cddb8eb625845a459a  file_u.tmp

В результате получается, что бинарные данные, после конкатенации с ASCII строкой, увеличили свой внутренний размер в байтах (но не в символах) с 4 до 7, но только в случае, если, ничего не значащий, UTF-8 флаг у ASCII был установлен.

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

Таким образом, бинарные данные могут увеличиться в размере и получить UTF-8 флаг, при этом никакого бага нет, все встроенные функции Perl обрабатывают их точно так же, как если бы флага не было (если и есть исключения, то баг в них).

Любой другой perl код тоже должен обрабатывать такие данные без ошибок (если он не пытается анализировать внутреннюю струкутуру строки, или хотя бы анализирует её правильно)

На самом деле то, что случилось с бинарными данными, является аналогом операции utf8::upgrade. Данные были интерпретированы как Latin-1, конвертированны в UTF-8, и установлен UTF-8 флаг. Это операция противоположнаutf8::downgrade, описанной выше. utf8::downgrade может производиться только с Latin-1 символами. А utf8::upgradeможет производиться
с любыми байтами (т.к. любому байту соответствует символ из Latin-1).

Это может быть важно, если у вас в памяти большой объём бинарных данных. Совсем не здорово, если 400 мегабайтный блоб, вдруг превращается в 700 мегабайтный, только потому, что вы добавили туда один ASCII-7bit байт с UTF-8 флагом. Хороший выход из ситуации здесь — unit тесты или runtime assertions с проверкой UTF-8 флага.

В общем случае, невозможно отличить байты от символов

Рассмотрим задачу: написать функцию, на вход которой будет подаваться XML, если XML является байтами, посмотреть кодировку в теге «xml» и перекодировать их в символы. Если она уже является символами, ничего не делать.

Такую функцию реализовать не получится. Например, для строки символов «Hello, München», функция не сможет
отличить символы это, или байты в кодировке CP1251, или в KOI8-R (в случае если строка окажется downgraded, а это программист в общем случае не контролирует).

Для символов > 255, UTF-8 флаг всегда установлен (с ними нельзя сделать utf8::downgrade). Для символов с кодом <= 127 UTF-8 бит не важен, в том плане, что их можно рассматривать и как бинарные данные, и как символы. Для символов Latin1 — отличить от байтов не возможно.

Отличить байты от символов в Perl — это всё равно что отличить имя файла от email и от имени человека. Иногда возможно, но в общем случае — нет. Сам программист должен помнить в какой переменной что у него находится.

Это есть в документации:

perldoc.perl.org/perlunifaq.html

How can I determine if a string is a text string or a binary string?

You can’t. Some use the UTF8 flag for this, but that’s misuse, and makes well behaved modules like Data::Dumper look bad. The flag is useless for this purpose, because it’s off when an 8 bit encoding (by default ISO-8859-1) is used to store the string.

This is something you, the programmer, has to keep track of; sorry. You could consider adopting a kind of «Hungarian notation» to help with this.

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

Wide characters не выдаётся для символов из Latin-1

Слудеющий пример выдаёт warning Wide characters in print только если мы печатает $s2

use strict;
use warnings;
use utf8;
my $s1 = "ß";
my $s2 = "тест";
my $s = $ARGV[0] ? $s1 : $s2;
print $s;

 

Если мы печатем $s1, Perl конвертирует Unicde символ µ (U+00DF, UTF-8 \xC3xF9) в байт \xDF и пытается вывести его на экран.
Такое же поведение справедливо для всех функций, которые принимают байты, а не символы (print, syswrite без указания кодировки, контрольные суммы SHA, MD5, CRC32, MIME::Base64).

Вирусный downgrade

В начале статьи было описано «вирусное» поведение UTF-8 бита у ASCII символов (вирусный utf8::upgrade). Теперь рассмотрим «вирусный» сброс UTF-8 бита у Latin-1 символов (utf8::downgrade).

Представим, что мы пишем функцию, которая определена только над байтами, а не над символами, хорошим примером являются hash-функции, шифрование, архивирование, Mime::Base64 и т.д.

1. Раз невозможно отличить бинарные данные от символов, вы должны рассматривать входные данные как байты.
2. Байты могут иметь upgrade форму (т.к. с UTF-8 флагом). Результат должен быть такой же как у downgrade формы.

Следовательно нужно сделать utf8::downgrade и выдать ошибку, если это не получится.

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

Как, наверное, многие знают, в Perl все параметры передаются по ссылке, но обычно используются по значению.

sub mycode
{
  $_[0] = "X"; # модифицировали первый фактической параметр, не зависимо от воли вызывающего
}

 

sub mycode
{
  my ($arg1) = @_; # типичный способ работы с аргументами функции
  $arg1 = "X"; # теперь параметр доступен по значению, фактический параметр не модифицируется
}

 

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

Для имён файлов всё это не работает

Функции, которые принимают имена файлов как аргументы (open, файловые тесты -X), а так же, которые возвращают имена файлов (readdir), не подчиняются этим правилам (это отмечено в документации).

Они просто интерпретируют имя файла, как оно есть в памяти.

Алгоритм их работы можно описать следующим образом:

sub open {
 my ( ... $filename) = @_;
 utf8::_utf8_off($filename); # теперь это двоичные данные
 _open($filename);

 

Для этого есть несколько причин:

1. Во многих POSIX системах ( Linux / *BSD ), на многих файловых системах, именем файла может являться произвольная последовательность байтов, не обязательно являющаяся последовательностью символов в какой-либо кодировке.
2. Нет переносимого способа определить кодировку файловой системы.
3. На машине может быть несколько файловых систем с разной кодировкой
4. Нельзя опираться на предположение, что кодировка имён файлов совпадёт с кодировкой локали.
5. Должна быть совместимость со старым кодом.

В итоге программист должен сам определять кодировку и сообщать её интерпретатору, но API для этого ещё не сделали.

Модифицируем наш пример, где мы «случайно» наткнулись на downgrade строки символов.

use strict;
use warnings;
use Digest::SHA qw/sha1_hex/;
use utf8;
my $s = "µ";
my $s1 = $s;
my $s2 = $s;

my $digest = sha1_hex($s2); # попробуйте закомментировать эту строчку

print STDERR "s1 and s2 are equal\n" if $s1 eq $s2;

open my $f, ">", "$s1.tmp" or die "s1 failed: $!";
print $f "test";
close $f;

open $f, "<", "$s2.tmp" or die "s2 failed: $!";
print STDERR "Done\n";

 
Результат работы:

s1 and s2 are equal
s2 failed: No such file or directory

т.е. строки s1 и s2 совпадают, но указывают на разные файлы, если вывоз sha1_hex убрать, то на одинаковые файлы.

На эти же грабли можно наткнуться, обращаясь к любым модулям, работающим с файлами (например File::Find)

Когда ещё это не работает

В модуле Encode Есть функция decode_utf8
документирована как:

Equivalent to $string = decode(«utf8», $octets [, CHECK])

Но на самом деле, если у $octets установлен флаг UTF-8, функция просто возвращает их неизменными (хотя должна попытаться сделать utf8::downgrade и работать с ними, как с бинарными данными, а если downgrade не получится, выдать ошибку Wide characters).

Этот баг был замечен ( RT#61671 RT#87267 ) сразу как появился — в 2010 году.

Но майнтайнер отвергает все подобные багрепорты. При этом суть репортов, даже не в том чтобы функция вела себя правильно (в соответствии с идеей Perl), и даже не в том, чтобы к ней была документация, описывающая это поведение, а в том, что, хотя бы, это поведение не должно противоречить существующей документации. Майнтайнер же считает, что функции документированы как эквивалентные, а это не значит идентичные (хотя помоему эквивалентность может рассматриваться и как похожесть, и как идентичность). Возможно в математике эквивалентность не содержит даже намёка на идентичность… Если кто-то сможет разгадать эту загадку, буду очень благодарен.

The Unicode Bug

В downgraded форме Latin-1 нельзя отличить от байтов, следовательно, в этой форме, плохо работают некоторые метасимволы в регулярных выражениях, функции uclcquotemeta.

Воркэраунд — utf8::upgrade, либо, в новых версиях Perl — некоторые директивы, которые позволяют сделать это поведение консистентным.

Подробное описание в документации Perl

Что же делать со всем этим?

1. Не пользуйтесь (если вы точно не знаете, что делаете) следующими функциями: utf8::is_utf8Encode::_utf8_on,Encode::_utf8_off, и всеми функциями из модуля bytes (документация ко всем этим функциям не рекомендует их использование, кроме как для отладки)

2. Пользуйтесь utf8::upgrade, utf8::downgrade, всякий раз, когда этого требует спецификация Perl

3. Для конвертации из символов в байты пользуйтесь Encode::encodeEncode::decode

3. Если используете чужой код, нарушающий эти правила, проверьте его на наличие багов, применяйте workaroundы.

4. При работе с именами файлов, либо придётся использовать wrapper над всеми функциями, либо, с помощью тестов убедиться, что внутреннее представление имён файлов не меняется в процессе работы кода.

Есть несколько примеров, когда нарушение этих правил мне показалось оправданным.

Encode::_utf8_off($_[0]) if utf8::is_utf8($_[0]) && (bytes::length($_[0]) == length($_[0]));

 
(сбрасывет UTF-8 флаг для ASCII-7bit текста (тем самым удаётся достичь 30% увеличения производительности регэкспов, во всех Perl, кроме 5.19)

defined($_[0]) && utf8::is_utf8($_[0]) && (bytes::length($_[0]) != length($_[0]))

 
(Возвращает TRUE, если у строки установлен UTF-8 флаг, и при этом она не является ASCII-7bit. Может использоваться в unit тестах, чтобы убедиться, что ваши 400 мегабайт бинарных данных не превращаются в 700)

Есть ещё вариант ничего не делать. Честно говоря, пройдёт довольно много времени, прежде чем вы наткнётесь на какой-либо баг (но, к тому моменту будет уже поздно). Этот вариант крайне не рекомендуется для разработчиков библиотек.