この記事は C++ Advent Calendar 2021 の23日目の記事です。
はじめに
私は個人開発で ゲームエンジン を作っています。最近の更新頻度は少ないですが、下回りを支える基本ライブラリは仕事でちょっとしたモノを作るときに使ったり、細々と続けています。
このゲームエンジンの中に独自の String クラスが含まれているのですが、これまでは UTF-16 をベースに作成されていました。それを UTF-32 ベースに書き直しました。しかしこの String クラスはライブラリの初版からここに至るまで、何度も大きな仕様変更が入っています。というところで、以前の仕様がなぜ悪かったのか、なぜ UTF-32 を選んだのか、今後の仕様の揺れを防ぐ目的も持ちつつ、書き残しておこうと思います。
結論から
UTF-32 を選んだ理由
- UTF-32 はメモリを多く使用するが、UTF-8, UTF-16 と比べてプログラム内では扱いやすく、間違いを防止できるため。
- 今日、ゲームエンジンを動かすようなリッチな環境で、文字列によるメモリ消費量を気にするケースは少ないと考えられるため。
なぜ UTF-16 ではないのか
- 文字列処理でサロゲートペアのケア忘れによる不具合を撲滅したかった。
- 歴史的な理由で UTF-16 を採用しているプログラミング言語やフレームワークが多いと考えた。今 UTF-16 を選ぶのは、それらとの互換性を重視する場合に限られるだろう。
なぜ UTF-8 ではないのか
- ASCII 以外の文字をマルチバイトで表すため、UTF-16のサロゲートペア同様の問題がある。
- UTF-8 は UTF-16, UTF-32 と比べて、プログラム内ではなくファイル保存や通信、API境界の引数受け渡しに適している。
歴史的な理由?
Java, C#, VB.NET, JavaScript など、String のエンコーディングに UTF-16 を採用している言語はたくさんあります。Qt 等の GUI フレームワークでもよく見られます。
これらが何故 UTF-16 を採用したのか、いろいろ情報を集めていくと、だいたい「昔は全ての文字が 16bit で収まると考えられていた」に行きつきました。
- Why Java char uses UTF-16?
- Supplementary Characters in the Java Platform
- Why does .net use the UTF16 encoding for string, but uses UTF-8 as default for saving files?
- Why does Windows use UTF-16LE?
もともと 2byte で表せると考えていたところへサロゲートペアというものが入ってきたことで、非常に中途半端なエンコーディングになってしまったように思います。
BMP 外の文字を扱うためのサロゲートペアを考慮した文字列のパースは、いわゆるマルチバイト文字の存在を常に気を付けなければならないという UTF-8 が抱える問題と変わりはありません。
プログラマはそのようなケースに常に気を付けなければなりませんので、どちらを採用しても神経を尖らせることになります。であれば、メモリ消費が少なく、エンディアンの問題も無い UTF-8 を採用するでしょう。
一方メモリがたくさん使える環境であればそもそも特殊なパースの必要が無い UTF-32 を採用すると、考えることが少なくなるため難易度は下がります。
今 UTF-16 をベースとしているシステムは、互換性維持を目的としたものがほとんどではないでしょうか。Java もかつて char32 のような UTF-32 に対応した標準的な文字型を追加する議論があったようですが、やはり互換性のために見送られたようです。
プログラム内で扱う文字列にどれを採用するか
作りたいもののコンセプトと相談です。
- メモリ消費量
- 変換コスト(実装難易度・処理速度)
- エンディアン
- 互換性
例えば次のような感じになります。
- 組み込み機器のようなリソースの限られた環境では、メモリ消費を抑えるために UTF-8 を使う。
- HTTP 通信を頻繁に行う環境では、変換コストを抑えるために UTF-8 のまま扱う。
- 様々なデバイスに対応することを想定し、エンディアン対策のため UTF-8 を使う。
- 開発速度を優先する場合、余計な気を回さなくていいように UTF-32 を使う。
- Java, C# といった言語から呼び出されるライブラリを書くときは、変換コストを抑えるために UTF-16 を使う。(Python や Ruby なら UTF-8)
正解はありませんが、こういった選択肢があるのは C++ のメリットのひとつなのかと思う… けど、一方でキホンは全部 UTF-8 でいいじゃんってすごく思います。
ほら Rust みたいにさ。char8_t がんばって。
String クラスの開発
ここからはややポエム的です。自分のゲームエンジンで色々な形の String クラスを作ってきたのですが、それぞれの特徴や問題点と合わせて振り返ってみます。
そもそもなんで文字列クラス自作してるの?
ちゃんとエンコーディングが定められていて、よく使うユーティリティが付いた String がほしいです。
既に u16string や u32string はありますが、これらはホントにただのコンテナとしてしか使えないのがツライです。
to_string のような変換関数は無いし、 string 系の標準ライブラリは char か wchar_t 用だし、 codecvt は非推奨になったりしてます。
あと split とか join とかが欲しいです。
他の言語を覚えると C++ の string のパワー不足に閉口な感じですが、 こちらは STL コンテナとして汎用的な API や実装を提供するのがコンセプトらしいので、便利な関数の追加には慎重とのことです。
split はずっと昔から提案され続けているのでそのうち実装されるかもですが、ちょっと待ってられないなって思います。
[ver.1] char 型の String
初版です。10年くらい前かも。「プログラミング言語C++」の 第1版 か 第2版 くらいのを穴が開くほど読み込み、string や vector 自作のサンプルを真似たのが最初でした。
その当時は若く、この世には 💣 ASCII と Shift_JIS しか存在しないと思っていました。 💣
char 型はエンコーディングを規定しません。環境に依存します。日本語 Windows 上の VisualC++ でコンパイルされたら、char の文字列リテラルは Shift_JIS になります。(OS のコードページに依ると思うけど)
そういったことを知らずに Shift_JIS に最適化されてしまった String クラスは、しかしながら日本語 Windows 上で動く VisualC++ と極めて相性がよく、自分以外にも文字コードに疎かったゲーム開発チームの開発速度を爆上げしてくれました。設計のベースはありえんですが、結果的には成功となりました。
Linux の勉強を始めたころ、過ちに気づき、泣き、悔い、コードをすべて捨て、次の日には新しい開発を始めたことを覚えています。
反省
世界には様々なエンコーディングがあることを知っておくべきでした。日本語だけでも Shift_JIS や EUC-JP その他色々があったり、例えば中国でも似たような符号化方式を持つ Big5、GB2312 等があります。
それら全てをカバーするのは非常に困難ですので、やはり何かしらひとつに固定したいところです。
この時点で char8_t があればまた違った未来があったのかもしれません。
[ver.2] wchar_t 型の String
一体何を反省したのでしょうという感じになりました。
wchar_t も char 同様、エンコーディングを規定していません。それどころか環境によってはそもそも型のサイズが異なります。
エンコーディングもランタイム環境に依存し、UTF-16 だったり UTF-32 だったりします。
ただ少なくとも一度は過ちに気づいたため、wchar_t の性質を一応は理解したうえでの苦肉の策だっと記憶しています。
独自の文字型に伴う標準ライブラリの再開発はやりたくなかったので、
- できるだけ標準ライブラリを使える
- 主要な環境 (Windows と Linux) である程度エンコーディングが定まる (UTF-ナントカ だけ気にすればよい)
ことを期待したのだと思います。
反省
標準ライブラリの再開発という茨の道を選ぶことができなかった (というか時間が無かった) ため、一番バッチリ決めてほしいことを決めずに進めてしまいました。
ただこのころは macOS や Linux で開発することは無かったため、結果的に Windows 前提、UTF-16 で上手く動いていたようです。
[ver.3] String<T>
テンプレートで提供することにしてみました。basic_string と同じく型引数で char でも wchar_t でも好きな方を指定して使ってね、というスタイルです。
つまりなんというか、決めるべきを決めず、すべてをユーザーに丸投げしました。
頑張って作ったようですが、ライブラリユーザーの負担が激増したため、長生きはしなかったようです。
反省
一番決めるべきが何かを考え抜けていなかったのだと思います。汎用性を重視するのか、効率を重視するのかといったような、ライブラリのコンセプトをはっきりして一貫させるような覚悟が必要そうでした。
今後、設計の前に "自分はどこを目指したいのか" を考えるきっかけのひとつになったと思います。
[ver.4] char16_t 型の String
C++11 で提供された char16_t を使い始めました。
標準ライブラリ関数やストリーム入出力のサポートがなかったり、厳密には STDC_UTF_16 とかチェックが必要だったり色々とあるのですが、それでもサイズが定まっていてUTF-16が想定されているということで、改めて検討を始めました。
検討というのはつまり、標準ライブラリ再開発みたいな茨の道を歩む覚悟をひたすら問うていました。
で作っちゃったのですが、結果的にはかなり長い間お世話になりました。これまでは長くても1年くらいで直していたのですが、これは5年以上使っています。
テストも気合入れて書いてしまったので そこそこ安定して動き、このモジュールだけ取り出してゲームエンジン以外でも色々使っていました。
ちなみに当時は UTF-32 はあまり考えておらず、C# や JavaScript から呼ばれるプログラムを作ることが多かったので、UTF-16 を採用しました。あと U"abc"
よりも u"abc"
の方が小指が痛くならなかったり、小さいから可愛いとか仕様もない理由もありました。
反省
よかったけれども今回のような UTF-32 移行への動機が出てきてしまったので、冒頭の "歴史的な理由" まで調べ切っておくべきでした。
[ver.5] char32_t 型の String
動機は次のとおりです。
- ユーザープログラム側でのサロゲートペア考慮忘れによる不具合がちらほら出ていた。
- メモリ制限をちょっと意識する 32bit システムを相手にすることがほとんどなくなってきた。
- Godot が UTF-32 を採用した のが気になった。
- 調べていくうちに、冒頭の "歴史的な理由" がもっと気になった。
ただし全部 UTF-32 ではなくて、こんな↓感じにしています。
- 内部エンコーディングは UTF-32 を使用する
- API の境界では UTF-8 を使用する
後者については、他の言語から呼び出される機能を作るときは UTF-8 で文字列の受け渡しを行うということ。
もしかしたらコンパイルスイッチで UTF-16 と選択式にするかも。それぞれ、呼び出し元の言語に合わせて変換コストを抑えるために提供します。
さいごに
小指以外は非常に快適です。
でも、もし文字が 32bit でも足りない!となったら同じように更新かけるかもです。