PHP 7.2 の開発版で導入された mb_chr、mb_ord、mb_scrub を試す


## 概要


PHP 7.2 の開発版 (2016年9月時点で master ブランチ)に `mb_chr`、`mb_ord`、`mb_scrub` が導入されました。これらの関数は私 (masakielastic) が実装を提案し、yohgaki さんが検証と導入をしました。

PHP のソースコードの NEWS ファイルには次のような記載があります。

  * Mbstring
    * Implemented request #66024 (mb_chr() and mb_ord()) (Masakielastic, Yasuo)
    * Implemented request #65081 (mb_scrub()) (Masakielastic, Yasuo)

## polyfill-mbstring を利用する

mbstring がインストールされていない環境もしくは PHP 7.2 以前のバージョンで `mb_chr`、`mb_ord`、`mb_scrub` を採用したい場合、Symfony の polyfill-mbstring コンポーネントを導入する選択肢があります。Composer による

```bash
composer require symfony/polyfill-mbstring
```

ただし、この記事で書かれている機能がすべて再現されているとはかぎりません。

## 開発版をビルドする

最新の履歴をだけをもつ shallow clone をつくります。

```bash
git clone --depth=1 git@github.com:php/php-src.git
cd php-src
./buildconf
./configure --enable-mbstring
make
```

mbstring モジュールが読み込まれているか確認しましょう。

```bash
sapi/cli/php -m | grep mbstring
```

## intl モジュールを有効にする

PHP 7.0 で導入された `IntlChar::chr`、`IntlChar::ord` を比較のために使いたい場合、intl モジュールを有効にするビルドオプションを追加します。

```bash
./configure --enable-mbstring --enable-intl
```

## shallow を解除してリポジトリを更新する

`git fetch` もしくは　`git pull` を実行する際に `--unshallow` オプションを指定して shallow を解除します。shallow を解除しないと `merge` されません。

```bash
git pull --unshallow
```

## 関数が利用できるか確認する

次のコマンドを実行して小文字の「a」が表示されるか試してみましょう。

```bash
sapi/cli/php -r 'echo mb_chr(0x61), PHP_EOL;'
```
今度はスクリプトファイルを用意して実行してみましょう。

```bash
sapi/cli/php test.php
```

```php:test.php
var_dump(
    'a' === mb_chr(0x61),
    0x61 === mb_ord('a')
);
```

## PHP 5.6 以降の php.ini のディレクティブ

仕様変更の経緯については yohgaki さんが策定した[RFC](https://wiki.php.net/rfc/default_encoding) をご参照ください。

PHP 5.6 以降 `mbstring.internal_encoding`、`mbstring.http_input`、`mbstring.http_output` は非推奨になりました。これらのディレクティブの値を設定しない場合、`default_charset` の値が使われます。`default_charset` のデフォルト値は `UTF-8` です。

```php
var_dump(
    'UTF-8' === ini_get('default_charset')
);
```

PHP 5.6 以降では `htmlspecialchars` の文字エンコーディングのデフォルト値に `default_charset` が使われます。PHP 5.4、5.5 では `htmlspecialchars` の文字エンコーディングのデフォルト値が `UTF-8` でした。

同時に `internal_encoding`、`input_encoding`、`output_encoding` ディレクティブが追加されました。これらのデフォルト値が空の場合、`default_charset` の値が使われます。

RFC によれば、`htmlentities`、`mb_strlen`、`mb_regex` などの関数のデフォルト値に `internal_encoding` が使われるとのことですが、2016年9月時点の調査では `ini_set` でディレクティブの値を変更しても `mb_internal_encoding` の戻り値には反映されませんでした。

```php
ini_set('internal_encoding', 'Shift_JIS');

var_dump(
    'UTF-8' === mb_internal_encoding()
);
```

## 関数の仕様

### mb_chr

```bash
string mb_chr(int $codepoint[, string $encoding = mb_internal_encoding()])
```

`mb_chr` はコードポイントを文字に変換する関数です。関数のパラメータは `$codepoint` と `$encoding` です。`$codepoint` はコードポイントです。コードポイントは1文字をあらわす整数の値です。コードポイントの値は UTF-8 や Shift_JIS などの文字エンコーディングごとに異なります。`$encoding` は省略できます。

関数の戻り値はコードポイントに対応する文字です。

最初に UTF-8 を試してみましょう。

```php
var_dump(
    "あ" === mb_chr(0x3042)
);
```

日本語フォントや絵文字がインストールされていない端末でスクリプトコードを開くことを想定する場合、PHP 7.0 で導入された Unicode エスケープシーケンスを使うことができます。

```php
var_dump(
    "\u{3042}" === mb_chr(0x3042)
);
```

Shift_JIS の場合、文字を構成するバイト列をそのまま16進数であらわしたものです。

```php
$char = mb_convert_encoding('あ', 'Shift_JIS', 'UTF-8');

var_dump(
   $char === mb_chr(0x82a0, 'Shift_JIS'),
   "82a0" === bin2hex(mb_chr(0x82a0, 'Shift_JIS')),
   "\x82\xa0" === mb_chr(0x82a0, 'Shift_JIS')
);
```

レガシーエンコーディングのバイト数の上限は4バイトです。もしそれ以上のバイト数がある事例があったら教えてください。中国語の文字エンコーディングである GB18030 で絵文字を試してみましょう。私は中国語のフォントがインストールされた端末をもっていないので、どのように表示されるのかは確認していません。

```php
// U+1F418: Elephant
$char = mb_convert_encoding("\u{1F418}", 'GB18030', 'UTF-8');

var_dump(
   $char === mb_chr(0x9439cb38, 'GB18030'),
   "\x94\x39\xCB\x38" === mb_chr(0x9439cb38, 'GB18030')
);
```

今度はUTF-8 の無効なコードポイントを試してみましょう。戻り値は `mbstring.substitute_character` の値に対応する文字です。デフォルト値は U+003F です。

```php
var_dump(
    '?' === mb_chr(-1),
    "\u{003f}" === mb_chr(-1)
);
```

UTF-8 では代替文字として U+FFFD が定義されています。

```php
mb_substitute_character(0xfffd);

var_dump(
    "\u{fffd}" === mb_chr(-1)
);
```

`ISO-2022-JP` や `BASE64` のような通信や変換の用途に限定される文字エンコーディングが指定された場合、警告が発せられ、戻り値は `false` になります。

```php
var_dump(false === mb_chr(0x61, 'ISO-2022-JP'));
```

### mb_ord

```bash
int mb_ord(string $char[, string $encoding = mb_internal_encoding()])
```

`mb_ord` は文字をコードポイントに変換する関数です。`mb_ord` のパラメータは `$char` と `$encoding` です。`$char` は文字で、`$encoding` は省略できます。戻り値は `$char` に対応するコードポイントです。

まずは UTF-8 を試してみましょう。

```php
var_dump(
    0x3042 === mb_ord('あ'),
    0x3042 === mb_ord("\u3042")
);
```

不正なバイト列が指定された場合、`mbstring.substitute_character` の値が返されます。

```php
var_dump(
    0x3f === mb_ord("\x80")
);
```

代替文字の U+FFFD を試してみましょう。

```php
mb_substitute_character(0xfffd);

var_dump(
    0xfffd === mb_ord("\x80")
);
```



### mb_scrub

```bash
string mb_scrub(string $str[, string $encoding = mb_internal_encoding()])
```

`mb_scrub` は文字列に含まれる不正なバイト列を代替文字に置き換える関数です。scrub の意味は「ゴシゴシと汚れを落とす、ゴシゴシと洗う」と言った意味です。Ruby 2.1.0 で導入された `String#scrub` メソッドの名前から採用しました。

`mb_scrub` のパラメータは `$str` と `$encoding` です。`$encoding` は省略できます。戻り値はパラメータの文字列に含まれる不正なバイト列を代替文字に置き換えたものです。代替文字には `mbstring.substitute_character` の値が使われます。

```php
var_dump(
    "あ?" === mb_scrub("あ\x80")
);
```

別の代替文字を試してみましょう。

```php
mb_substitute_character(0xfffd);

var_dump(
    "あ\u{fffd}" === mb_scrub("あ\x80")
);
```

`mb_scrub` の代わりに `mb_convert_encoding` を使って不正なバイト列を置き換えることもできます。

```php
var_dump(
    "あ?" === mb_scrub("あ\x80"),
    "あ?" === mb_convert_encoding("あ\x80", mb_internal_encoding())
);
```

標準関数の `htmlspecialchars` と `htmlspecialchars_decode` を組み合わせることで同じ処理ができます。

```php
var_dump(
    "あ&\u{fffd}" === htmlspecialchars_decode(htmlspecialchars("あ&\x80", ENT_SUBSTITUTE))
);
```

intl モジュールが導入されていれば `UConverter` を使うことができます。このクラスは PHP 5.5 の時代に導入されました。

```php
var_dump(
    "あ\u{fffd}" === UConverter::transcode("あ\x80", 'UTF-8', 'UTF-8'),
    "あ\u{fffd}" === (new UConverter('UTF-8', 'UTF-8'))->convert("あ\x80")
);
```

## 開発者ノート
### UTF-8 の基礎知識

UTF-8 の有効なコードポイントは U+0000 から U+D7FF、U+E000 から U+10FFFF までの範囲です。U+D800 から U+DFFF の範囲はサロゲート文字と呼びます。UTF-16 では U+FFFF までのコードポイントしか使えないので、U+10000 から U+10FFFF までの範囲の文字をあらわすのにサロゲートペア (2つのサロゲート文字) を使います。

自分で UTF-8 の処理を書く場合、次の記事をご参照ください。

 * [UTF-8 の文字からコードポイントを求める](http://qiita.com/masakielastic/items/5696cf90738c1438f10d)
 * [コードポイントから UTF-8 の文字を生成する](http://qiita.com/masakielastic/items/68f81e1b7d153ee5cc81)
 * [不正なバイト列を考慮して UTF-8 の文字列から1文字ずつ取り出す](http://qiita.com/masakielastic/items/432b774569b44f959b1c)

### mb_chr

無効なコードポイントが引数として指定された場合、関数のふるまいとして fatal error を発して処理を停止させる選択肢があります。この選択肢の場合、関数のユーザーは処理を停止させないために有効なコードポイントを知っている必要があります。世界にはさまざまな文字エンコーディングが存在するので、CMS やフレームワークの開発者に過大な知識が要求されてしまいます。

このことから標準関数を実装する場合、「その関数を使うための最小限の知識とは何か？」を問う必要があることが言えるでしょう。

`mb_chr` の引数に関して、ループを使わずに複数のコードポイントから文字列を生成する利便性を考えるなら、第1引数が配列を受け取るもしくは JavaScript の `fromCodePoint` のように可変引数を選ぶことが考えられます。コードとデータを分離したり、要素数の多い配列を扱うことを考えると、配列を受け取る方式のほうが使いやすいのではないでしょうか。

### mb_ord
不正なバイト列が指定された場合、`null` や `false` を返す選択肢があります。intl の `graphme_strlen` や `u` オプションを指定した `preg_match`、`json_encode` の戻り値はそのような仕様になっています。このような仕様の問題はユーザーが関数に入力する前にあらかじめ不正なバイト列の対応をしておくことが要求されてしまうことです。不正なバイト列を代替文字に置き換える処理には `mb_convert_encoding`、後述の `mb_scrub`、` UConverter::transcode`、`UConverter::convert` を使います。しかしながら、文字エンコーディングの知識がなければ、不正なバイト列とは何かということがわからなければ、`false` や `null` を返されても何をすればよいのかわからないでしょう。

CMS やフレームワークでこの関数を使う場合、どこで不正なバイト列を代替文字に変換する処理をどこでやるのかを頭に入れる必要があります。

以上のことから、不正なバイト列に遭遇したとき `null` や `false` を返すもしくは fatal error にする仕様は選びませんでした。

関数のふるまいについて参考にしたのは Go です。Go では for 文と `range` を組み合わせて、文字列から1文字ずつ取り出す場合、不正なバイト列を U+FFFD に変換します。Go のふるまいの調査については[こちらの記事](http://qiita.com/masakielastic/items/01a4fb691c572dd71a19)をご参照ください。Go の作者であるロブ・パイクは UTF-8 の開発にも携わりました。

### mb_scrub
私が知るかぎり、PHP 5.2 の時代から不正なバイト列を置き換えるために `mb_convert_encoding` が使われていましたが、変換前と変換後の文字エンコーディングが同じであるために、はじめてコードを見た人には何をやっているのかまったくわからないでしょう。関数の名前を提供されることで、どんな処理をやっているのか伝わりやすくなります。

前述のとおり、mb_scrub の名前は Ruby の `String#scrub` から採用しました。開発の経緯については[こちら](https://bugs.ruby-lang.org/issues/6752)の記事をご参照ください。ユースケースは文字列の切り詰めや部分文字列の取り出し操作によって壊れてしまった文字が含まれているときの対応です。

不正なバイト列に関連するセキュリティ問題は 徳丸先生 (ockeghem) の「[文字コードの脆弱性はこの3年間でどの程度対策されたか?](http://www.slideshare.net/ockeghem/owasp20134021-x)」(2014年) のスライドや「体系的に学ぶ 安全なWebアプリケーションの作り方」(2011年、SBクリエイティブ) をご参照ください。

## 今後の課題

 * PHP 公式マニュアルの追加
 * 標準関数の `chr`、`ord` に文字エンコーディングオプションを追加する提案をする
 * `mb_scrub` に相当する `str_scrub` の提案