Haxeの文字列


イントロダクション

Haxeはマルチプラットフォームに出力できるのが売りの1つですが、プラットフォーム間で非互換の部分を把握していないと落とし穴にはまりかねません。ここでは、Haxeでマルチプラットフォームで動くように文字列を扱う話を書こうと思います。

エンコーディングを理解する

まずHaxeの文字列のエンコーディングの話をしましょう。Haxeのドキュメントを読むと、String Encoding - Haxeという記事があるのに気づきます。しかし、実際に読んでみても、いったい何のエンコーディングの話をしているのかがよく分からないと思います。どうもソースファイルのエンコーディングの話と、内部エンコーディングの話がごっちゃになって書かれているようです。(Haxeの場合はさらにHaxeの出力したターゲット言語のソースファイルのエンコーディングも絡んでくるのでややこしいです。)

HaxeのソースコードのエンコーディングにはUTF-8かISO/IEC 8859-1が使えますが、UTF-8を使うことを強く推奨します。日本語を使いたい場合は、当然UTF-8を使いますよね。

プログラムが実行されるときの文字列の内部エンコーディングは、プラットフォームごとに異なっています。現在Haxeが対応しているプラットフォームのうち、NekoVM, PHP, C++と、マクロを実行するインタープリターの内部エンコーディングはUTF-8に、それ以外のプラットフォームではUTF-16になります。(これはソースコードをUTF-8で書いた場合の話です。)

スタンダードAPI

Haxeの標準APIは、Stringに対して一応共通のAPIを提供していますが、プラットフォームの内部エンコーディングの差を吸収するようなことはしてくれません。文字列のインデックスは、UTF-8ならバイト数で、UTF-16ならコードユニット数で指定することになります。

class Example1 {
    public static function main() {
        trace("naïve".charAt(4)); // UTF-8なら"v"、UTF-16なら"e"になる
        trace("?あëa".length); // UTF-8なら10、UTF-16なら5になる
    }
}

haxe.Utf8

UTF-8の文字列を「文字」ごとに処理するにはこれだけでは足りないので、haxe.Utf8が用意されています。これを使えば、内部エンコーディングがUTF-8の環境でコードポイントを単位とした処理が行えます。しかし、内部エンコーディングがUTF-16の環境では単にStringのメソッドを呼び出しているだけなので、サロゲートペアを含む例では相変わらず異なる結果となってしまいます。

class Example2 {
    public static function main() {
        trace(haxe.Utf8.charCodeAt("naïve", 4)); // UTF-8でもUTF-16でも101になる
        trace(haxe.Utf8.length("?あëa")); // UTF-8なら4、UTF-16なら5になる
    }
}

現状ではサロゲートペアの処理をしてくれるAPIは用意されていないので、自力で処理する必要があります。そのことを知らずにhaxe.Utf8を使うと、互換性がなく、文字化けを生じるプログラムを書いてしまいかねません。Utf8は上記のUTF-8を内部エンコーディングとしている環境だけで使いましょう。

とりあえずこの非互換のissueをGitHubに投げ(#1975)ユニットテストも送った(#1977)のですが、今のところ、

// disable until we decide how to handle JS/SWF API being UCS2 and not UTF8

ということで無効化されてしまいました。

デコードとエンコード

恐ろしいことに、haxe.Utf8にはdecodeとencodeというメソッドが用意されています。decodeは内部エンコーディングがUTF-8の文字列をISO/IEC 8859-1に「デコード」し、encodeはISO/IEC 8859-1の文字列をUTF-8に「エンコード」します。

class Example3 {
    public static function main() {
        var str = haxe.Utf8.decode("naïve");
        trace(str.charCodeAt(4)); // 101
        trace(haxe.Utf8.encode(str)); // UTF-8で出力される
    }
}

ISO/IEC 8859-1のほうが1文字1バイトで分かりやすいためにこのようなメソッドが用意されているのでしょうが、ISO/IEC 8859-1に入らない文字がある場合の結果は未定義です。また、内部エンコーディングがUTF-16の環境では例外が投げられます。絶対にこのような方法でUnicode文字列を処理してはいけません。

charCodeAtの非互換

charCodeAtは内部エンコーディングがUTF-8かUTF-16かで違う動作をしますが、非互換はそれだけではありません。Java向けのコードでは、内部的にJavaのcodePointAtを使ったコードが生成されます。charCodeAtとcodePointAtでは、サロゲートペアに対しての挙動が違うことを理解しないと、サロゲートペアを正しく処理できません。

ソリューション

環境によらず一定の結果を得るために、私はライブラリを書きました。Haxeの内部エンコーディングの差を吸収し、同じ操作に対して同じ結果を得ることができます。

mandel59/unifill

Gitリポジトリのパッケージは、haxelib git コマンドを使うと簡単にインストールできます。

$ haxelib git unifill https://github.com/mandel59/unifill unifill
using unifill.Unifill;
class Example4 {
    public static function main() {
        trace("naïve".uCharAt(4)); // "e"
        trace("?あëa".uLength()); // 4
    }
}

このライブラリは、以前Haxe 2用に書いたhxUnicodeをもとに、Haxe 3用に書きなおしたものです。

今のところ、異常な値への対策はまだ全く書いていないので、現状では文字列が正しくエンコーディングされていることや範囲外のインデックスを指定していないことを何らかの方法で確認する必要があります。

明日は未定です。

土曜日は@shohei909さんです。よろしくおねがいします。