PHPのロケールに関するまとめ
PHPのロケール*1まわりについて調査したので、これをまとめてみます。
この記事は「ロケールの影響を受ける関数 - Sarabande.jp」を掘り下げたものです。masakielasticさん、ナイスな記事をありがとうございます。
PHPの文字列型と文字エンコーディング
他のモダンなLL言語と異なり、PHPは文字列の文字エンコーディングに関して何も仮定せず、単なるバイト列として管理しています。つまり、文字エンコーディングの取り扱いは各関数の実装に委ねられています。
下記の通り、これはマニュアルにも記述があるのですが、実に残念なことです。
残念ながら、PHP の各関数が文字列のエンコーディングを判断する方法はまったく統一されていません。
とはいえ、引用元の文章では各関数と文字エンコーディングの関係を下記のように大別しています。
- 文字エンコーディングを仮定せず、文字列をバイト列とバイトオフセットで扱う関数(substr, strposなど)
- 内部エンコーディングに従うか、または引数で文字エンコーディングを指定する関数(htmlentities, mbstring関数全般)
- ロケール設定に従う関数(本稿の話題、strcasecmpなど)
- UTF-8など特定の文字エンコーディングを前提とする関数(u修飾子利用時のPCRE関数など)
これを見ると、それぞれの関数の出自によって挙動が変わっているようです。PHP本体の実装として、OSの機能に頼ったり他所から持ってきたライブラリに頼ったりと実装がバラバラであることが混乱の一因だといえるでしょう。
本稿では、3番目の分類にある「ロケール」について詳しく見ていきます。
ロケールにまつわるトラブル
ロケールとは、OSが持っている多言語対応の仕組みです。たとえばヨーロッパの国々では異なる文字コードを利用していますが、特定の1バイトがアルファベットか記号かをロケール設定に応じて判定する機能(ライブラリ関数)をOSが提供しています。PHPの一部関数でも、こうしたロケール関連のライブラリ関数を利用しているというわけです。
ところで、ロケールはOSが提供しているわけですから、環境依存ということになります。実際、ロケール情報の差やパッケージのインストール状況によって挙動が変わってしまうことがあるのです。
特にDebian系のLinuxディストリビューションではロケールが別パッケージになっていることもあり、ロケールなしで動作していることがあります。また、特にShift_JISについてはロケール情報がそもそも提供されていなかったり、誤りを含んでいたりすることがあります。
そんなこんなで、ロケールまわりのトラブルは珍しくありません。複数環境で使うようなプログラムであれば、ロケールを明示的に設定し、設定に成功したかを確認すべきです。もしくは、ロケールに依存する関数を避けることも検討事項になるでしょう。
<?php if (setlocale(LC_ALL, 'ja_JP.UTF-8') === false) { error_log('Locale not found: ja_JP.UTF-8'); exit(1); } //本来の処理
ロケール設定に従う関数一覧
masakielasticさんの指摘の通り、どのPHP関数がロケール設定に従うのかPHPマニュアルでは網羅されていません。今回、これを独自に調べてみました。
下記のリストはPHPのCソースコードをgrepするなどして怪しいPHP関数を抜き出したものです。過不足があるかもしれませんので、お気づきの方はお知らせください。
1バイト文字について大文字小文字などの判定・変換や、大小比較をするもの
toupper(3)などctype.hが提供するライブラリ関数を利用しているPHP関数群です。UTF-8文字列に対してCロケールまたはUTF-8ロケールで利用する限り、特にトラブルは無いはずです。一方で、誤ってISO8859系のロケールが設定されていると意味不明なバグに悩まされそうです。
- array_change_key_case ― 配列のすべてのキーの大文字小文字を変更する
- array_multisort ― 複数の多次元の配列をソートする(引数でSORT_LOCALE_STRINGを指定したときだけ)
- array_unique ― 配列から重複した値を削除する(第二引数がSORT_LOCALE_STRINGのときだけ)
- arsort ― 連想キーと要素との関係を維持しつつ配列を逆順にソートする(第二引数がSORT_LOCALE_STRINGのときだけ)
- asort ― 連想キーと要素との関係を維持しつつ配列をソートする(第二引数がSORT_LOCALE_STRINGのときだけ)
- ctype_alnum ― 英数字かどうかを調べる
- ctype_alpha ― 英字かどうかを調べる
- ctype_cntrl ― 制御文字かどうかを調べる
- ctype_graph ― 空白以外の印字可能な文字かどうかを調べる
- ctype_lower ― 小文字かどうかを調べる
- ctype_print ― 印字可能な文字かどうかを調べる
- ctype_punct ― 空白、英数字以外の出力可能な文字かどうかを調べる
- ctype_space ― 空白文字かどうか調べる
- ctype_upper ― 大文字かどうか調べる
- krsort ― 配列をキーで逆順にソートする(第二引数がSORT_LOCALE_STRINGのときだけ)
- ksort ― 配列をキーでソートする(第二引数がSORT_LOCALE_STRINGのときだけ)
- lcfirst ― 文字列の最初の文字を小文字にする
- natcasesort ― 大文字小文字を区別しない"自然順"アルゴリズムを用いて配列をソートする
- rsort ― 配列を逆順にソートする(第二引数がSORT_LOCALE_STRINGのときだけ)
- sort ― 配列をソートする(第二引数がSORT_LOCALE_STRINGのときだけ)
- str_ireplace ― 大文字小文字を区別しない str_replace()
- strcasecmp ― 大文字小文字を区別しないバイナリセーフな文字列比較を行う
- strcoll ― ロケールにもとづいて文字列を比較する
- stripos ― 大文字小文字を区別せずに文字列が最初に現れる位置を探す
- stristr ― 大文字小文字を区別しない strstr
- strnatcasecmp ― "自然順"アルゴリズムにより大文字小文字を区別しない文字列比較を行う
- strncasecmp ― バイナリセーフで大文字小文字を区別しない文字列比較を、最初の n 文字について行う
- strripos ― 文字列中で、特定の(大文字小文字を区別しない)文字列が最後に現れた位置を探す
- strtolower ― 文字列を小文字にする
- strtoupper ― 文字列を大文字に変換する
- ucfirst ― 文字列の最初の文字を大文字に変換する
- ucwords ― 文字列の各単語の最初の文字を大文字にする
その他の国別の表記をするもの
その他のロケール情報を利用する関数群です。ヨーロッパ圏では数値や日時の表記が国ごとに異なるため、こうした関数が重要になるのでしょう。
下記関数のうち、strftime関数を使って曜日を漢字表記できる環境があります。これを愛用している人がいるかもしれませんが、全ての環境で利用できるものではありませんので注意してください。
- fprintf ― フォーマットされた文字列をストリームに書き込む(小数点文字、printfと同様)
- gmstrftime ― ロケールの設定に基づいて GMT/UTC 時刻/日付をフォーマットする
- localeconv ― 数値に関するフォーマット情報を得る
- money_format ― 数値を金額文字列にフォーマットする
- printf ― フォーマット済みの文字列を出力する(フォーマット指定%f,%G,%gでの浮動小数点数の小数点文字。%Fなど他のフォーマット指定ではロケール設定によらずドットになる)
- sprintf ― フォーマットされた文字列を返す(小数点文字、printfと同様)
- strftime ― ロケールの設定に基づいてローカルな日付・時間をフォーマットする
- strptime ― strftime() が生成した日付/時刻をパースする
- vfprintf ― フォーマットされた文字列をストリームに書き込む(小数点文字、printfと同様)
- vprintf ― フォーマットされた文字列を出力する(小数点文字、printfと同様)
- vsprintf ― フォーマットされた文字列を返す(小数点文字、printfと同様)
1文字が何バイトかの判定をするもの
mblen(3)を利用してマルチバイト文字の範囲を特定している関数/クラスメソッド/ストリームです。セキュリティクラスタの人がワクワクするような項目が並んでいますが、本稿ではこれ以上の深追いはしません。
- basename ― パスの最後にある名前の部分を返す
- error_log ― エラーメッセージを送信する(第2引数がmessage_typeが1(=メール)で、かつphp.iniでmail.force_extra_parametersまたはmail.add_x_headerが指定された場合)
- escapeshellarg ― シェル引数として使用される文字列をエスケープする
- escapeshellcmd ― シェルのメタ文字をエスケープする
- exif_read_data ― JPEG あるいは TIFF から EXIF ヘッダを読み込む(返り値のFileNameについてbasenameを利用しているため、ファイル名にマルチバイト文字を含んでいると不正な値を返す可能性あり)
- exif_thumbnail ― TIFF あるいは JPEG 形式の画像に埋め込まれたサムネイルを取得する(内部的にbasenameを呼び出しているが、おそらく無害)
- fgetcsv ― ファイルポインタから行を取得し、CSVフィールドを処理する
- mail ― メールを送信する(第5引数が指定されたか、php.iniでmail.force_extra_parametersまたはmail.add_x_headerが指定された場合)
- mb_send_mail ― エンコード変換を行ってメールを送信する(第5引数が指定されたか、php.iniでmail.force_extra_parametersまたはmail.add_x_headerが指定された場合)
- pathinfo ― ファイルパスに関する情報を返す
- str_getcsv ― CSV 文字列をパースして配列に格納する
- tempnam ― 一意なファイル名を生成する(第2引数prefixについて内部的にbasenameを呼び出している)
- DirectoryIterator::getBasename ― 現在の DirectoryIterator のアイテムのベース名を取得する
- DirectoryIterator::getExtension ― パスからファイルの拡張子の部分を返す
- SplFileInfo::getBasename ― ファイルのベース名を取得する
- SplFileInfo::getExtension ― ファイルの拡張子を取得する
- ZipArchive::addGlob (オンラインマニュアルにはページ無し)
- ZipArchive::addPattern (オンラインマニュアルにはページ無し)
- ZipArchive::extractTo ― アーカイブの内容を展開する
- ftpストリーム
- zipストリーム
まとめ
ロケール周りはCプログラマなら鬼門の一つとして印象が強い場所だと思うんですが、Cを知らないPHPプログラマからすると何のこっちゃ的な話題だと思うんですよね。そのくせ説明不足という酷い状況だったわけですから、今回のまとめは有意義なんじゃないかと思っています。
参考リンク
- PHP: 7.9 Groß- und Kleinschreibung
- PHP :: Bug #37738 :: basename does not work with Japanese
- なぜPHPのfgetcsv()はロカール依存という糞仕様なのか - muddy brown thang
最初のリンク先はドイツ語のPHP教科書で、strtoupper関数でöをÖにしています。実際、こういうニーズはあるんでしょうね。とはいえ、ISO8859-1ならこれで動いていたのがUTF-8にしたら動かなくなってしまうわけですから、ヨーロッパの人も大変そうです。
2番目のリンク先は、内部エンコーディングをUTF-8にしているのにbasename関数で日本語を含むファイル名の処理に失敗するという指摘です。おそらくデフォルトのロケール指定が誤っているだけで、setlocaleすれば期待通りに動く気がします。PHP6で何とかするという返答がありますが、この混乱をなんとかしたいという意味でしょう。