HaxeでUnicode文字列をどう取り扱うか

Haxeには複数のターゲットがあり、文字列のAPIは共通だが、それぞれで文字列の内部表現が異なっている。普通にプログラムを書いても、ターゲットによって結果が変わってくる。

class Main {

	public static function main() {
		var s = "\u{20000}𩸽あëa";
		trace(s);
		trace(s.length);
		trace(s.charCodeAt(1));
	}

}
$ haxe -main Main.hx -python main.py
$ haxe -main Main.hx -js main.js
$ haxe -main Main.hx -php main_php
$ python3 main.py
Main.hx:5: 𠀀𩸽あëa
Main.hx:6: 5
Main.hx:7: 171581
$ node main.js
𠀀𩸽あëa
7
56320
$ php main_php/index.php 
Main.hx:5: 𠀀𩸽あëa
Main.hx:6: 14
Main.hx:7: 160

macro, Neko, PHP, C++UTF-8C#, Java, JavaScript, SWFはUTF-16*1、Python3はUTF-32*2である。

現時点でhaxe.Utf8とhaxe.Ucs2という、クロスプラットフォームでのUnicode文字列処理に使えそうにみえる標準APIが用意されている。これは罠だ。決して使ってはいけない。haxe.Utf8は内部表現がUTF-8のターゲットでだけ使えるものだし、haxe.Ucs2はbeyond-BMPの処理を全く考慮しないものである。

ではどうすればいいというのか。安心してほしい、私は、Haxeで書かれたプログラムをお手軽にUnicodeに対応させるためのライブラリを開発した。

mandel59/unifill · GitHub

しかし、このライブラリはまだドキュメントが整備されていないので、どのように使えばよいのかよくわからないと思う。そこで、とりあえずここに使い方をしるす。

使い方には3通りの方法がある。

  • Unifillを使う
  • InternalEncodingを使う
  • UtfXを使う

Unifillを使う

一番簡単なのは、unifill.Unifillを使うことである。何も考えずHaxeで作られたプログラムをとりあえずUnicode対応にしようというときは、この方法を使うとよいだろう。

unifill.Unifillは、Haxeの標準APIで提供されている文字列操作関数とほぼ同等のメソッドを、using mixinで提供する。使うには、まず冒頭でmixinを使う宣言をし、文字列操作メソッドをUnifillで提供されているメソッドで置き換えるだけである。それだけで、文字列があたかもUTF-32であるかのように取り扱うことができる。

using unifill.Unifill;

class Main {

	public static function main() {
		var s = "\u{20000}𩸽あëa";
		trace(s);
		trace(s.uLength());
		trace(s.uCharCodeAt(1));
	}

}

この方法の利点は、メソッドを置き換えるだけなので導入が楽であることと、Unicodeの知識がない人間にも比較的分かりやすいことである。すべてのメソッドを適切に置換することさえできれば、もはやサロゲートペアにまつわるバグに悩まされることはない。

欠点は、それぞれのメソッドの計算量が文字列長nに対してO(n)であることである。つまり、次のようなコードを書けば、計算量はO(n^2)ということになる。

var s = "\u{20000}𩸽あëa";
var i = 0;
while (i < s.uLength()) {
  trace(s.uCharAt(i++));
}

代わりにuIteratorを使えばO(n)になる。

var s = "\u{20000}𩸽あëa";
for (c in s.uIterator()) {
  trace(c.toString());
}

uIteratorはIteratorである。CodePointはabstract Intで、toIntでIntに、toStringでStringに変換できる。

InternalEncodingを使う方法

unifill.InternalEncodingは、Stringの内部表現に関わらず同等の結果を得るためのAPIを提供している。コードユニットで数えたインデックスによる指定を基本にしたAPIになっているためにUnifillを使うよりも扱いが難しいが、より柔軟な文字列操作を実装することができる。

index by code pointはコードポイントを単位としたときのインデックスであり、index by code unitはコードユニットを単位としたときのインデックスである。文字列中の位置指定にindex by code pointを使うUnifillとは違い、InternalEncodingではindex by code unitを使う。

例えば、文字列s = "𠀀𩸽あëa"をUTF-8で表現したときの、コードユニットとコードポイントの対応は次の表のようになる。

0, 1, 2, 3 4, 5, 6, 7 8, 9, 10 11, 12 13 index by code unit
f0 a0 80 80 f0 a9 b8 bd e3 81 82 c3 ab 61 code unit sequence
0 1 2 3 4 index by code point
U+20000 U+29E3D U+3042 U+00EB U+0061 code point sequence
𠀀 𩸽 ë a string

文字列の内部表現がUTF-8の環境で InternalEncoding.codeUnitAt(s, 4) とすれば 0x29E3D が返ることになる。しかしこれでは同じ内容の文字列でも、文字列の内部表現によってインデックスが異なってしまう。ではどうするのかというと、文字列のコードポイント境界をたどるためのメソッドが用意されている。

public static inline function codePointWidthAt(s : String, index : Int) : Int;
public static inline function codePointWidthBefore(s : String, index : Int) : Int;
public static inline function offsetByCodePoints(s : String, index : Int, codePointOffset : Int) : Int;

codePointWidthAtは指定された位置のコードポイントのコードユニット数を返す。

codePointWidthBeforeは指定された位置の1つ前のコードポイントのコードユニット数を返す。

offsetByCodePointsは指定された位置から指定されたオフセット分だけコードポイントで数えて進んだ(戻った)位置のindex by code unitを返す。

また、InternalEncodingIterというクラスも用意されている。実際には上のメソッドよりもこれを使うのが便利だろうと思う。このイテレータは各コードポイントの先頭のindex by code unitを返す。

var s = "\u{20000}𩸽あëa";
var itr = new unifill.InternalEncodingIter(s, 0, s.length);
while (itr.hasNext()) {
  trace(unifill.InternalEncoding.charAt(s, itr.next()));
  // 内部表現がUTF-8の環境で itr.next() は 0, 4, 8, 11, 13 を返す
}

あるいはfor文を使って

var s = "\u{20000}𩸽あëa";
var itr = new unifill.InternalEncodingIter(s, 0, s.length);
for (i in itr) {
  trace(unifill.InternalEncoding.charAt(s, i));
}

UtfXを使う

3つ目の方法は、unifill.Utf8, unifill.Utf16, unifill.Utf32を使う方法だ。これらはネイティブ文字列の内部表現に関わらず、それぞれ名前の通りのエンコーディングの文字列を保持している。ネイティブのエンコーディングと同じエンコーディングのUtfXはネイティブの文字列をそのまま保持しているので、O(1)で変換可能だ。それ以外のエンコーディングに変換する場合はO(n)となる。

この方法は、可変長エンコーディングを真面目に取り扱うと誓える人間だけが使って良い。サロゲートペアを無視するぐらいなら、少々性能が劣っていてもUnifillを使うほうが万倍マシである。

未実装だが、haxe.Bytesからの変換も可能にする予定だ。

計算量について

Haxeが文字列の取り扱いをマトモにやってくれないのは、どうも余計な抽象化によって性能を劣化させたくないからのようで、やたらとインデックスアクセスなどが定数時間であることを気にするのだが、しかし文字列全体にアクセスするのであれば見てきたように、適切なコードを使えばトータルの計算量はO(n)で、可変長でも固定長でもオーダーは同じだ。beyond-BMPの対応を切り捨ててHaxeのデフォルトのエンコーディングUCS-2にしようなんてのは損失が大きい割に得なことはほとんどないので、絶対にやめてほしい。(絵文字使うでしょ?)

validateについて

Unifillライブラリは現時点でエラーチェックを基本的にしていない。Stringに格納されている文字列がvalidであること、境界外のアクセスはしないことを前提としてあり、この前提が崩れた場合の動作は不定である。不正な文字列が渡ってくる可能性がある場所では、事前に文字列がvalidであることを確認する必要がある。validate用メソッドは、例外を投げるvalidateメソッドと、Boolで返すisValidStringメソッドが準備されている。

APIの安定性について

UnifillライブラリのAPIは未だunstableで、確定したものではない。改善の要望があればIssueに投げて欲しい。

*1:JavaScriptUCS-2だという声に惑わされてはいけない。実際のところUTF-16である。

*2:Python3の文字列の内部表現は本当はUTF-32とは限らないのだが、抽象化されてUTF-32であるかのようにみえる。