dorapon2000’s diary

忘備録的な。セキュリティとかネットワークすきです。

Perlの文字化けとutf8とEncodeの関係

ブログの記事のサンプルコードを書いているときにPerlで文字化けに悩まされました。文字化けはせずとも、Wide character in say atという警告が表示されたりすると、記事のサンプルコードとしては締りが悪いです。もうPerlで日本語まったくわからん... そこで、いろいろ実験してPerlの文字化け・警告と仲良くなろうというのがこの記事の主旨です。use utf8;encode_utf8を使っているのに文字化けが直らない!という人がいれば、助けになるかも知れません。

目次

検証環境

文字化けに関わるコード

Perl 文字化け」で検索してヒットする記事で出てくるPerlのコード達です。

# コード中に埋め込まれた文字列リテラルが内部文字列として解釈されるようになる。
use utf8;

# それぞれ、utf8のバイナリを内部文字列に変換するメソッドと、内部文字列からutf8のバイナリに変換するメソッド
use Encode qw/ decode_utf8 encode_utf8 /;

# 標準入力からのutf8の文字列が内部文字列へ変換される
binmode STDIN,  ":utf8";

# 標準出力する際に、内部文字列がutf8のバイナリへ変換される
binmode STDOUT, ":utf8";

# 標準エラー出力する際に、内部文字列がutf8のバイナリへ変換される
binmode STDERR, ":utf8";

お、お手柔らかに...

これら以外にもファイルの読み書きに関するものもあります。今回は標準入出力とコード中の文字の埋め込みだけに絞って考えていきます。

内部文字列とutf8のバイナリ

Perlはどうやらプログラムで扱う専用の文字列と入出力用の文字列で異なるものとして扱うようです。前者を「内部文字列」、後者を「バイナリ」と呼びます。内部文字列はこちらとしてはどう管理されているか使っている側は気にしなくてもいいはずですが、utf8だそうです。

f:id:dorapon2000:20220223180737p:plain

つまり、Perlで文字列を扱う際は内部文字列にしてからバイナリに戻すという作業が必要ということになります。

ということで...

検証① decode_utf8とencode_utf8

内部文字列にしてからバイナリに戻してみる

decode_utf8encode_utf8を使います。

use 5.30.0;
use strict;
use warnings;

use Encode;

my $en = decode_utf8("English");
say encode_utf8($en), " : ", length($en), " 文字";

my $ja = decode_utf8("日本語");
say encode_utf8($ja), " : ", length($ja), " 文字";
English : 7 文字
日本語 : 3 文字

f:id:dorapon2000:20220223185100p:plain

文字列をdecode_utf8で内部文字列にしたあと、sayで標準出力する際にencode_utf8でバイナリに戻しています。length($en)は返り値が文字列ではなく数字なので(Perlでは文字列と数字を区別しませんが)、とくにencode_utf8する必要がないはずです。

では、"English""日本語"以外の" : "" 文字"の部分はencode_utf8しなくていいんでしょうか。これはしなくていいですね。" : "" 文字"は内部文字列ではなくバイナリです。"English""日本語"は文字数を数えるという処理のために、一旦内部文字列に変換する必要がありましたが、" : "" 文字"は特に処理の対象ではないです。

内部文字列にしない

内部文字列にしないと何が困るのでしょうか。試してみます。

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
English : 7 文字
日本語 : 9 文字

「日本語」が9文字としてカウントされてしまいました。utf8は日本語1文字を3バイトで表現するので、3 x 3 = 9になったようです。

しかし、「English」の方は内部文字列にしていないにも関わらず、正しくカウントできています。これは内部文字列も実体はutf8で、バイナリもutf8で、utf8は英字1文字を1バイトで表現するという偶然の結果なのだと思います。しっかりするなら「English」だけであってもencode_utf8するべきなのでしょう。

文字化けさせてみる

文字化けさせてみましょう。引数に内部文字列を渡すべきencode_utf8でバイナリを渡せば文字化けしそうです。

my $en = "English";
say encode_utf8($en), " : ", length($en), " 文字";

my $ja = "日本語";
say encode_utf8($ja), " : ", length($ja), " 文字";
English : 7 文字
æ¥æ¬èª字

f:id:dorapon2000:20220223185320p:plain

予想通り、文字化けました。

警告を表示させる

Wide character in say atを表示させてみます。これは内部文字列をバイナリに変換せず直接出力しようとすると出るようです。

my $en = decode_utf8("English");
say $en, " : ", length($en), " 文字";

my $ja = decode_utf8("日本語");
say $ja, " : ", length($ja), " 文字";
English : 7 文字
Wide character in say at /Users/.../mojibake.pl line 14.
日本語 : 3 文字

f:id:dorapon2000:20220223191038p:plain

警告が出ましたが、表示としては問題ありません。これはutf8である内部文字列をutf8として画面上に表示しようとして、偶然文字化けしなかった形でしょうね。Shift-JIS環境などで表示すると文字化けると思います。

個人的には"English"の方も警告が表示されると予想していたので、警告が1つしか表示されないのは意外です。

encode_utf8とdecode_utf8を間違えて逆にする

my $en = encode_utf8("English");
say decode_utf8($en), " : ", length($en), " 文字";

my $ja = encode_utf8("日本語");
say decode_utf8($ja), " : ", length($ja), " 文字";
English : 7 文字
日本語 : 18 文字

f:id:dorapon2000:20220223194726p:plain

文字列の長さがおかしいので逆をやったことに気づけますが、そうでないと標準出力からは気づけないという罠です。エンコード→デコードの順に使いたくなってしまうのも非常にわかります。

検証② utf8プラグマ

decode_utf8を使わず内部文字列にする

use utf8;はコード中に埋め込まれている文字列リテラルを内部文字列として解釈するためのプラグマです。これを使うとdecode_utf8のひと手間が要らなくなります。

use 5.30.0;
use strict;
use warnings;

use Encode;
use utf8;

my $en = "English";
say encode_utf8($en . " : " . length($en) . " 文字");

my $ja = "日本語";
say encode_utf8($ja . " : " . length($ja) . " 文字");
English : 7 文字
日本語 : 3 文字

f:id:dorapon2000:20220223192615p:plain

utf8プラグマとencode_utf8だけで内部文字列を介したやり取りができました。ポイントは、検証①ではencode_utf8していなかった" : "" 文字"もencode_utf8の中に入れる必要があるということです。これらはプログラムの処理の対象ではないですが、utf8プラグマは彼らも内部文字列に変換してしまうのです。

警告を表示させる

確認のため、" : "" 文字"をencode_utf8せず標準出力すると、Wide character in sayが出るはずです。

use utf8;

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
Wide character in say at /Users/.../mojibake.pl line 11.
English : 7 文字
Wide character in say at /Users/.../mojibake.pl line 14.
Wide character in say at /Users/.../mojibake.pl line 14.
日本語 : 3 文字

f:id:dorapon2000:20220223193119p:plain

出ました。

文字化けさせてみる

use utf8;しているのにdecode_utf8すると文字化けするはずです。

use utf8;

my $en = decode_utf8("English");
say encode_utf8($en . " : " . length($en) . " 文字");

my $ja = decode_utf8("日本語");
say encode_utf8($ja . " : " . length($ja) . " 文字");
English : 7 文字
Wide character at /Users/.../mojibake.pl line 13.

f:id:dorapon2000:20220223193740p:plain

何も出力されませんでした笑。おかしな使い方であることは間違いないです。

検証③ binmode STDOUT

encode_utf8を使わずに標準出力する

binmode STDOUT, ":utf8";とすると、標準出力される際に、内部文字列をutf8のバイナリに変換して出力されます。

use 5.30.0;
use strict;
use warnings;

use utf8;
binmode STDOUT, ":utf8";

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
English : 7 文字
日本語 : 3 文字

f:id:dorapon2000:20220223195859p:plain

今までで一番すっきりしましたね。

文字化けさせる

ここまでの検証を踏まえれば、文字化けの方法も無数に気づけます。例えばutf8プラグマを使わなければ、標準出力の際にバイナリをバイナリに変換というおかしなことになります。

binmode STDOUT, ":utf8";

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
English : 7 æå­
æ¥æ¬èªå­

"English"”日本語”にだけdecode_utf8を適応しても、" : "" 文字"が文字化けします。

use Encode;
binmode STDOUT, ":utf8";

my $en = decode_utf8("English");
say $en, " : ", length($en), " 文字";

my $ja = decode_utf8("日本語");
say $ja, " : ", length($ja), " 文字";
English : 7 æå­
日本語 : 4 æå­

f:id:dorapon2000:20220223200243p:plain

まとめ

この記事を書きながら、かなりPerlの文字の扱いについて理解が深まりました。

最初で紹介したようにいろいろ文字を扱う方法はあるけれど、すべてを適応しても文字化けを誘うだけということがよくわかります。今回は記事が長くなってしまったので扱いませんでしたが、ファイル入出力の文字コード指定しかりです。正直なところ、use utf8;が一番それっぽいので、これだけですべての文字化けが解決してくれると嬉しいんですけどねぇ。

参考