UTF Perl Practice / как использовать UTF-8 в перле

2021-01-27

← Назад в блог

Этот материал лежит здесь не только как «архивная» памятка. На практике Perl до сих пор очень удобен, когда нужно быстро перемолоть большие объёмы данных, собрать конвертер, почистить выгрузку или склеить legacy-интеграцию.

И как только в таких задачах появляются данные не в ASCII (кириллица, европейские языки, смешанные кодировки, старые CSV/DB dumps), очень легко наступить на юникодные грабли: double-encoding, mojibake, wide character и поломанные регулярки.

Лучшая статья по юникоду в перле, которую встречал, и которая, к сожалению, осталась только на веб-архиве: http://www.nestor.minsk.by/sr/2008/09/sr80902.html.

Переложил её сюда, чтобы с одной стороны, оно «искалось» в интернете, а с другой — чтобы насобирать откликов, так как проблема до сих пор актуальна, и все снова и снова с ней сталкиваются.

В перл-чатике пишут:

она кстати уже устарела как минимум в нескольких местах

Вступление

Для многих не секрет, что на данный момент восьмибитовые кодировки в значительной мере устарели. Основная тому причина — невозможность вместить в одну кодировку достаточное количество символов. Когда необходимо поддерживать ограниченное количество групп символов (к примеру, кириллицу и латиницу), мы можем воспользоваться koi8-r, cp1251 или iso-8859-5. Но если возникает потребность использовать несколько языков или специальные символы, то одной емкости одной кодировки становится недостаточно. Вот тут и может помочь использование юникода.

Для начала определим неясности с терминологией. Многие так или иначе сталкивались с понятиями Unicode, UTF-8, UTF-16, UTF-32, UCS-2, UCS-4, и все они так обозначали юникод. Что же значит каждый из них?

Unicode — Character Encoding Standart
Стандарт для цифрового представления символов использующихся во всех языках. Поддерживается и развивается Консорциумом юникода (unicode.org).
UCS — Universal Character Set
Международный стандарт ISO/IEC 10646, который идентичен стандарту Unicode.
UTF — Unicode (or UCS) Transformation Format
Способ представления символов Unicode в виде последовательности целых положительных чисел. - UTF-8, UTF-16, UTF-32 — различные UTF-трансформации, которые оперируют числами, занимающими соответственно 8, 16 и 32 бита. В UTF-8 минимальный размер символа — один октет (байт), максимальный — шесть. В UTF-16 минимальный размер — два октета, максимальный — четыре. В UTF-32 любой символ представляется в виде четырех октетов.
UCS-2, UCS-4
Способы кодирования по ISO/IEC 10646. Универсальный набор символов, закодированный двумя или четырьмя октетами (байтами) соответственно. UCS-2 полностью входит в UTF-16, но в UTF-16 есть составные символы (из четырех октетов), которые не входят в UCS-2. UCS-4 тождественна UTF-32.

Итог: Unicode — набор символов, определенным образом упорядоченных: каждому символу поставлена в соответствие кодовая позиция. Любая из кодировок UTF — это представление символов Unicode в виде последовательности чисел. Поэтому, говоря, например, о переводе проекта на юникод, в большинстве случаев мы подразумеваем поддержку какой-либо из трансформаций. При необходимости за уточнениями можно обратиться к словарю терминов www.unicode.org/glossary и документации на сайте unicode.org.

С точки зрения программиста наиболее комфортной для работы выглядит UTF-32. В этой кодировке мы имеем постоянный размер символа. Но с практической точки зрения, в простейшем случае, при использовании только латиницы, мы получаем затраты по объему в четыре раза (по сравнению с обычной восьмибитовой кодировкой). Вторая проблема при переходе на UTF-32 — необходимость полной замены исходного кода и всех текстов. Поэтому в качестве переходной альтернативы была разработана кодировка UTF-8. Ее особенность состоит в том, что часть символов, попадающая в ACSII, сохраняет свои коды и представления. В результате если у нас был исходный код, написанный в latin-1, то при переходе на UTF-8 ничего не нужно менять. На сегодня наибольшую популярность получила именно кодировка UTF-8, как наиболее комфортная для плавного перехода. Эту кодировку поддерживает подавляющее большинство программного обеспечения и средств разработки.

Аргументацией против перехода на юникод достаточно часто выступает ложное мнение о том, что перл его не поддерживает, или поддерживает недостаточно хорошо. Чаще всего такое мнение складывается из-за неправильного использования имеющихся средств. Миф о неумении перла работать с юникодом я и постараюсь развеять. Также аргументом может выступать излишний объем по сравнению с восьмибитовой кодировкой. Но если оценить, то получается что количество текста, который действительно увеличивается в объеме, по сравнению с общим объемом кода проекта, крайне незначителен. Под задачей перехода на юникод (а конкретно — на кодировку UTF-8) я буду понимать следующие требования: исходный код в UTF-8, корректная работа встроенных функций и регулярных выражений с использованием возможностей юникода, а также корректное взаимодействие с окружением.

Корень зла или суть проблемы

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

Возьмем любой текстовый редактор с поддержкой UTF-8, напишем в нем простой кириллический символ А и сохраним в файл. Посмотрим шестнадцатеричное представление этого файла. В нем будет два байта: 0xD090. Это и есть представление символа CYRILLIC CAPITAL LETTER A, закодированное при помощи UTF-8. Но считывая в программе на Perl UTF-данные из различных источников, мы получаем совершенно различные их представления. Если взять Data::Dumper и сделать дамп таких строк, то возможны следующие варианты:

"А"
"\x{410}"
"\x{d0}\x{90}"

В первом варианте мы имеем строку, про которую перл не знает, что это строка. Для него это набор байт. Во втором случае мы имеем юникодный символ с кодом 0410. Если обратиться к таблице символов Unicode, то мы узнаем, что это и есть CYRILLIC CAPITAL LETTER A. Третий случай — это два символа Unicode с кодами 00d0 и 0090. Первая строка — набор октетов без флага. Вторая строка — юникодный символ, на этой строке флаг включен. Третий случай — это с нашей точки зрения «поломанные» данные. На строке октетов был принудительно включен UTF-флаг, при этом каждый октет стал отдельным символом. В большинстве задач мы будем стремиться ко второму варианту.

Для начала нужно определиться, как мы можем преобразовать данные из одного представления в другой. Для данной задачи есть как минимум два хороших способа, каждый со своими плюсами и минусами — utf8::* и Encode::*.

utf8::* хорошо использовать, когда исходные данные пришли в нашу программу уже в кодировке UTF-8.

utf8::downgrade снимает флаг со строки:

utf8::downgrade("\x{d0}\x{90}") :'А'

utf8::upgrade выставляет флаг на строку:

utf8::upgrade('А') : "\x{d0}\x{90}"

utf8::encode преобразует символы в октеты, снимает флаг:

utf8::encode("\x{410}") : "А"

utf8::decode преобразует октеты в символы, выставляет флаг (флаг выставляется только в том случае, если строка содержит символы с кодами, большими 255; см. perunicode).

utf8::decode("А") : "\x{410}"

utf8::is_utf8 проверяет состояние флага. возвращает 1 в том случае, если на строке установлен utf-флаг:

utf8::is_utf8("\x{410}") = 1
utf8::is_utf8("\x{d0}\x{90}") = 1
utf8::is_utf8('А') = undef

Для использования данных функций не нужно выполнять use utf8; этот модуль всегда загружен и он не экспортирует свои функции, так что указывать utf8:: придется явно.

Encode::* хорошо использовать, когда исходные данные имеются в разных кодировках. Также данный модуль хорош для различных преобразований между кодировками. Некоторые функции аналогичны utf8::*.

_utf8_off снимает флаг со строки. _utf8_on выставляет флаг на строку. encode_utf8 преобразует символы в октеты, снимает флаг. decode_utf8 преобразует преобразует октеты в символы, выставляет флаг (см. комментарий по поводу utf8::decode). encode преобразует символы в октеты указанной кодировки, снимает флаг:

encode("cp1251","\x{410}") = chr(0xC0)

decode преобразует октеты указанной кодировки в символы, выставляет флаг (см. упомянутый комментарий).

decode("cp1251",chr(0xC0)) = "\x{410}"
decode("MIME-Header", "=?iso-8859-1?Q?Belgi=eb?=")
= "Belgi\x{451}" (Belgiё)

Теперь нам известно, как преобразовывать данные. Попробуем воспользоваться этим знанием на практике.

Исходный код

Напишем простую программу в кодировке UTF-8, запустим ее и посмотрим на вывод.

$_ = "А";

print Dumper $_; # "А"
print lc; # А
print /(\w)/; # nothing
print /(а)/i; # nothing

Как мы видим, строка оказалась без флага, встроенные функции (lc) не работают, регулярные выражения не работают. Воспользуемся уже известной нам функцией utf8::decode:

$_ = "А";
utf8::decode($_);
print Dumper $_; # "\x{410}"
print lc; # а
print /(\w)/; # А
print /(а)/i; # nothing ?

Теперь эта строка юникодная, встроенные функции работают, первое регулярное выражение работает. Что же не так со вторым? Проблема в том, что символ, находящийся в регулярном выражении, — тоже кириллический, и он остался без флага. Возможны достаточно сложные варианты, которые я встречал в различном коде:

print /(\x{430})/i;

или

use charnames ':full';
print /(\N{CYRILLIC SMALL LETTER A})/i;

или даже

$a = ''.qr/(а)/i;
utf8::decode($a);
print /$a/;

Но есть более удобный способ. Директива use utf8 «выполняет» utf8::decode( <SRC> ).

use utf8;
$_ = "А";
print Dumper $_; # "\x{410}"
print lc; # а
print /(\w)/; # А
print /(а)/i; # А

Все работает, никакой черной магии.

Также отмечу существование похожей директивы use encoding 'utf8'. Она делает почти то же самое, но use encoding, во-первых, не является лексической директивой (ее действие не ограничивается блоком, и при выходе из блока сохранится), во-вторых, обладает «магическим» поведением, сходным с source filters. В общем случае использование use encoding для utf-8 не рекомендуется.

Ввод и вывод

Итак, все работает, но при этом мы получаем странное предупреждение, которого раньше не было:

Wide character in print at...

Проблема заключается в том, что перл не знает, поддерживается ли utf-8 данным дескриптором. Мы можем ему об этом сообщить:

binmode(STDOUT,':utf8'); # binmode используется
# с уже открытым дескриптором

Точно так же возможно указать, что некоторый, открываемый нами файл — в кодировке UTF-8 через так называемые PerlIO Layers:

open my $f, '<:utf8', 'file.txt';

Также мы можем снять флаг со строки перед выводом (utf8::encode) и отдать дескриптору поток байтов. Но есть простая директива use open, которая поможет решить данные вопросы:

use open ':utf8'; # только для файлов
use open qw(:std :utf8); # файлы и STD*
# подробнее - perldoc open

Еще мы можем при помощи PerlIO указать поддерживаемую кодировку, если, например, хотим писать некоторый лог-файл в cp1251.

binmode($log, ':encoding(cp1251)');

Строки с флагом при работе с этим дескриптором будут автоматически переведены в указанную кодировку средствами PerlIO.
В результате вышеизложенного мы можем делать даже так:

use strict; use utf8; use open qw(:std :utf8);
my $все = "тест";
sub печатать (@) { print @_ }
печатать $все;

Для простоты можно сделать очень простой «прагматический» модуль, который будет для нас выполнять сразу все три действия, чтоб не писать трижды use:

package unistrict;
use strict(); use utf8(); use open();
sub import {
$^H |= $utf8::hint_bits;
$^H |= $strict::bitmask{$_} for qw(refs subs vars);
@_ = qw(open :std :utf8);
goto &open;::import;
}

И в дальнейшем:

use unistrict;

Что касается самого перла — это все, что нужно знать, для того, чтобы успешно использовать utf-8. Но еще мы рассмотрим на примерах, как скорректировать работу того или иного модуля, если он не соответствует нашим требованиям.

Окружение

Под окружением понимаются различные модули (как входящие в стандартную поставку, так и с CPAN), с которыми взаимодействует наше приложение. То есть, например, модуль бизнес-логики считается частью приложения и подразумевается, что он работает в подготовленной среде с корректными строками (с флагом), а вот модули, отвечающие за ввод и вывод — это окружение.

DBI.pm

По умолчанию большинство DBD возвращают данные без флага.

my $dbh = DBI->connect('DBI:mysql:test');
($a) = $dbh->selectrow_array('select "А"');
print '$a = ',Dumper $a; # 'А'

Но опять же, для большинства DBD уже сделана поддержка utf-8.

DBD::mysql : mysql_enable_utf8 (требует DBD::mysql >= 4.004)
DBD::Pg : pg_enable_utf8 (требует DBD::Pg >= 1.31)
DBI:SQLite : unicode (требует DBD::SQLite >= 1.10)

Пример использования:

my $dbh = DBI->connect('DBI:Pg:dbname=test');
$dbh->{pg_enable_utf8} = 1;
($a) = $dbh->selectrow_array('select "А"');
print '$a = ',Dumper $a; # "\x{410}"

Template Toolkit

В TT заявлена поддержка UTF-8, но при этом имеются некоторые особенности. Чтобы файл шаблона был воспринят и перекодирован в строки с флагом, в начале каждого файла должен быть так называемый BOM-header. BOM расшифровывается как Byte Order Mark (порядок следования байт). Но получается, что BOM имеет значение только для UTF-16 и UTF-32, у которых минимальная единица — два или четыре октета (байта). Для UTF-8 BOM по спецификациям признается опциональным. А если учесть, что в шелл-скриптах наличие BOM перед шебангом (например, #!/usr/bin/perl) «ломает» скрипт, то его использование зачастую вообще сомнительно. Для UTF-8 BOM — это три байта 0xEFBBBF или \x{feff}. Соответственно, если вы хотите, чтобы TT читал файлы без BOM, но при этом все корректно работало, предлагаю воспользоваться одним из двух вариантов решения проблемы:

package Template::Provider::UTF8;
use base 'Template::Provider';
use bytes;
our $bom = "\x{feff}"; our $len = length($bom);
sub _decode_unicode {
my ($self,$s) = @_;
# if we have bom, strip it
$s = substr($s, $len) if substr($s, 0, $len) eq $bom;
# then decode the string to chars representation
utf8::decode($s);
return $s;
}
package main;
my $context = Template::Context->new({
LOAD_TEMPLATES => [ Template::Provider::UTF8->new(), ] });
my $tt = Template->new( 'file',{ CONTEXT => $context }, ... );

или

package Template::Utf8Fix;
BEGIN {
use Template::Provider;
use bytes; no warnings 'redefine';
my $bom = "\x{feff}"; my $len = length($bom);
*Template::Provider::_decode_unicode = sub {
my ($self,$s) = @_;
# if we have bom, strip it
$s = substr($s, $len) if substr($s, 0, $len) eq $bom;
# then decode the string to chars representation
utf8::decode($s);
return $s;
}
}

package main;
use Template::Utf8Fix; # 1 раз в любом месте проекта
my $tt = Template->new( 'file', ... );

CGI.pm

Наиболее часто используемым модулем при разработке CGI-приложений начального уровня является CGI.pm. У него есть много недостатков (подробнее можно ознакомиться в докладе Анатолия Шарифулина c YAPC::Russia 2008: http://event.perlrussia.ru/yr2008/media/video.html), но тем не менее, модуль крайне популярен. Рассмотрим, что нужно сделать, чтобы получать от него переданные аргументы в виде строк с флагом.

Для версии ниже 3.21 рабочим способом может быть только переопределение метода param (аналогично примерам про TT). Начиная с версии 3.21 до 3.31 нужно указать кодировку раньше, чем будет обращение к методу param():

# Запрос: test.cgi?utf=%d0%90
use CGI 3.21;

$cgi->charset('utf-8');
$a = $cgi->param('utf');
print $cgi->header();
print Dumper $a; # "\x{410}"

Начиная с версии 3.31 данный способ перестает работать, но появляется другой способ: указание тега :utf8 при импорте:

# Запрос: test.cgi?utf=%d0%90
use CGI 3.31 qw(:utf8);

$a = $cgi->param('utf');
print $cgi->header();
print Dumper $a; # "\x{410}"

Замечания

Следует также обратить внимание на термины, касающиеся UTF-8. Официальное название кодировки — UTF-8. На вебе имя часто встречается в нижнем регистре — utf-8. В перле кодировка называется utf8. Различия между ними следующие:

* utf8 — unrestricted UTF-8 encoding. Нестрогая UTF-8. Это может быть любая последовательность чисел в диапазоне 0..FFFFFFFF.

* utf-8 — strict UTF-8 encoding. Строгая UTF-8. Это может быть только некоторая последовательность чисел из диапазона 0..10FFFF, которая регламентирована стандартом Unicode (см. unicode.org/versions/Unicode5.0.0).

Таким образом:
- utf-8 является подмножеством utf8;
- перл поддерживает любые, в том числе так называемые ill-formed последовательности.

Также хочу обратить внимание, что в регулярных выражениях метасимвол \w работает по-разному в зависимости от контекста. Так, при использовании qr/[\w]/, метасимвол будет воспринят в байтовой семантике (так как в случае перечисления всех символов класса \w из таблицы Unicode данный паттерн был бы крайне объемным и в результате — медленным).

Проблемы

В режиме utf-8 не используйте локали (см. perldoc perlunicode). Их использование может привести к неочевидным результатам.

Встроенные функции работают значительно медленнее на строках с флагом.

Также встречаются крайне странные и неприятные ошибки:

use strict;use utf8;
my $str = 'тест'; my $dbs = 'это тестовая строка';
for ($str,$dbs) {
sprintf "%-8s : %-8s\n", $_, uc;
print ++$a;
}
for ($dbs,$str) {
sprintf "%-8s : %-8s\n", $_, uc;
print ++$a;
}

Результат:

123panic: memory wrap at test.pl line 12.
или
use strict;
my $str = "\x{442}";
my $dbs = "\x{43e} \x{442}\x{435}\x{441}".
"\x{442} \x{43e}\x{432}\x{430}".
"\x{44f} \x{441}\x{442}\x{440}";
sprintf "%1s\n",lc for ($dbs,$str);

Результат:

Out of memory!

Также есть не совсем адекватное поведение:

use strict; use utf8;
print "1234567890123456780\n";
printf "%-4.4s:%-4.4s\n", 'itstest','itstest';
printf "%-4.4s:%-4.4s\n", "этотест","этотест";

Результат:

1234567890123456780
itst:itst
этот :этот

Данные проблемы связаны с ошибками в реализации встроенной функции sprintf. Так что отформатировать вывод юникодных строк при помощи %*.*s в sprintf не получится. Решение проблемы находится в стадии разработки.

Кроме того

Расскажу о паре интересных вещей, которые можно сделать, когда у нас имеются строки с UTF-флагом. Для начала упомяну достаточно интересный модуль Text::Unidecode:

use utf8;
use Text::Unidecode;
print unidecode "\x{5317}\x{4EB0}";
# That prints: Bei Jing
print unidecode "Это тест";
# That prints: Eto tiest

Данный модуль позволяет получить фонетическую транслитерацию большинства символов юникода в ASCII. Кстати, этот модуль используется на pause.perl.org при транслитерировании имен, содержащих символы, выходящие за рамки latin-1.

Еще одно достаточно интересное применение юникоду я нашел в проекте, который целиком работает на koi8-r. Нижеприведенный пример показывает, как можно пользоваться возможностями регулярных выражений с функционалом юникода, не переводя весь проект на UTF-8:

sub filter_koi ($) {
# передем koi8-r в строку
local $_ = Encode::decode('koi8-r', shift);
# заменим все html-entity
# соответствующими символами юникода
s{&#(\d+);}{chr($1)}ge;
# проведем некоторые замены
# пробельные символы пробелом
s{(?:\p{WhiteSpace}|\p{Z})}{ }g;

# все кавычки – двойными
s{\p{QuotationMark}}{"}g;

# минусы, дефисы, тире и т.п. - дефисами
s{\p{Dash}}{-}g;

# символ переноса тоже дефисом
s{\p{Hyphen}}{-}g;

# символ троеточия тремя точками
s{\x{2026}}{...}g;

# символ номера заменяем на N
s{\x{2116}}{N}g;
# Вернем строку обратно в кодировке koi8-r
return Encode::encode('koi8-r',$_);
}

Как известно, на странице, отданной, к примеру, в кодировке koi8-r, есть возможность ввести символы, не входящие в эту кодировку. Они приходят на серверную сторону в виде html-сущностей &#....; Хранить с ними данные неудобно, к тому же не всегда в качестве вывода используется HTML. Данная функция преобразует отсутствующие в кодировке символы в некоторые визуальные аналоги. Для поиска и замены используются классы символов (character class), такие как, например, QuotationMark. В него входят всевозможные кавычки из всех языков.

Ответы на многие вопросы можно найти в документации к перлу:

perldoc perluniintro
perldoc perlunicode
perldoc Encode
perldoc encoding

Автор с удовольствием ответит на любые вопросы по работе с юникодом в перле по электронной почте или в рассылке группы Moscow.pm.
Владимир Перепелица, Москва, mons@cpan.org


Ещё из перл-чатика по теме, начиная с коммента https://t.me/modernperl/178819:

Oleg Pronin, [22 Jan 2021 02:05:22]:
Я заметил, что многие путаются в utf8 сильно и получают wide character или кракозябры (двойной энкод etc). Хотя тема на самом деле дико простая.
Все что нужно знать:
1) у перла строка может представлять 2 сущности - byte stream и string. is_utf8 говорит какой из режимов включен. В режиме строки просто некоторые функции (substr, lenght И так далее) меняют свой behaviour с побайтового на посимвольный, что занимает дополнительное процессорное время естественно, потому что в пасяти все равно utf8.
2) внутренее представление не изменяется никак при переходе из одного режима в другой. decode_utf8 не делает нихрена кроме как проверяет что там нет инвалидных последовательностей utf8 и переключает режим.
3) чтобы не запутаться где какой режим, простое правило - все что приходит извне (чтение из сокета, файла, stdin, ...) - всегда байтовое. Соответсвенно все что туда отправляется должно быть тоже (иначе будет wide character, однако изза совпадения внутреннего прдставления, все будет работать).
4) еще простое правило - функция is_utf8 за крайне редкими исключениями не должна использоваться. Вы всегда должны знать где у вас байты а где символы. Для этого как правило сразу на входе декодят и в приложении используются символы (кроме случая когда данные бинарные должны быть), и энкодятся в самом конце перед записью в канал.
5) некоторые библиотеки избавляют вас от необходимости энкодить и декодить. Например json::xs::decode_json() ожидает от вас бинарный поток и порождает структуру с символами. А на encode ожидает символы и порождает байты. Обычно это интуитивно логично и ожидаемо.
В случаи работы через обьект JSON::XS->new->utf8->
Метод utf8 как раз заставляет его порождать на декод и ожидать на энкод символы. Если его не писать то в структуре которую вернет декод будут тоже байты (лучше так не делать, только если точно уверены что там только инглиш). Но на вход декод и выход энкод всегда байты, это не меняется, иначе бессмыслица.
Еще например template toolkit аналогично, считывает с диска байты, декодит в символы, ожидает от вас переменные в символах, рендерит и энкодит в байты итоговый результат.
6) многие считают что utf8 это символы. Нет. Это байты. Utf8 это способ сериализации кодов юникод, то есть бинарный режим. В перле вообще нет настоящего символьного режима. Он чисто виртуальный (выполняется в рантайме, разбирая каждый байт). Например когда строке в режиме символов говорят substr на 10й символ, перл не может херак и встать на 10й символ сразу как в бинарном режиме, ему придется линейно идти от начала строки отсчитывая символы из байтов.
Настоящий символьный режим был бы если бы перл представлял строку в памяти как utf32 - коды юникода (и памяти занимало бы в 2-4 раза больше, но работало бы быстрее). В перле в режиме символов всегда бинарный режим utf8 под капотом, и виртуальное рантайм эмуляция.
И да еще забыл
7) use utf8;
Не имеет ничего общего с рантайм перекодировками данных, это просто хелпер который автомат делает decode_utf8 на все литералы написанные в этом файле / области видимости. Таким образом все литералы становятся строками. Но вычитанные в рантайме нет!
Обычно мало захардкоженных не инглиш строк в программе, поэтому он не особо полезен.