マルチバイト文字を扱う際に気をつけること

# 基礎知識

- コンピュータで扱えるデータの最小単位は**1バイト**(8ビット:2進数8桁分)であり、これは符号無し10進数表記で**`0〜255`**(2進数表記で`00000000〜11111111`)を表すことが出来ます。
- 半角英数字や半角記号、改行コードなどの世界共通で多用される文字は**1バイト**文字として**`0〜127`**の範囲に割り当てられており、これらは「ASCII文字」と呼ばれます。
- ASCII以外の文字コードでは余った`128〜255`の範囲を他の文字を表現するために利用しています。但しこれらを1バイト単位で使うだけでは残り128種類の文字しか表せなくなってしまうので、実際には複数桁組み合わせて用いられます。これらは「マルチバイト文字」と呼ばれます。

以下は参考リンクです。初学者の方は、この記事を読む前にひと通り目を通されることをおすすめします。

-  [ASCII文字コード](http://e-words.jp/p/r-ascii.html)
-  [文字コードについての俺的まとめ](http://d.hatena.ne.jp/snaka72/20100710/SUMMARY_ABOUT_JAPANESE_CHARACTER_CODE)
-  [日本語文字コード](http://charset.7jp.net/)

# 文字コードに関するおさらい

## UTF-8

- マルチバイト文字は`2〜4`バイトの可変長で表されます。
- [接頭符号](https://ja.wikipedia.org/wiki/%E6%8E%A5%E9%A0%AD%E7%AC%A6%E5%8F%B7)です。 バイト列をどこで切っても、そのバイトを他の文字の先頭バイトと間違えることはありません。先頭バイトと後続バイトの領域がはっきり区別されているからです。

| 文字種 | 表現 |
|:-----|:-----|
|ASCII文字| [00-7F] | 
|ラテン文字など|[C0-DF][80-BF]|
|ひらがな・カタカナ・漢字など|[E0-EF][80-BF][80-BF]|
|一部の漢字・絵文字など|[F0-F7][80-BF][80-BF][80-BF]|

**注意: 厳密な定義ではありません<br />→ [UTF-8 の後続バイトの範囲チェックは 0x80 から 0xBF までだけでは不十分](http://qiita.com/masakielastic/items/abf8e54344034f9b089c)**

## EUC-JP

- マルチバイト文字は`2`バイトの固定長で表されます。
- 接頭符号ではありません。マルチバイト文字の1バイト目と2バイト目の範囲が重複します。
- **マルチバイト文字はASCII文字とは重複しません。**生成過程で両コードポイントに`A0`を足しているためです。ASCII文字は`7F`で終わっているため、確実にこれより大きな値になります。

| 文字種 | 表現 |
|:-----|:-----|
|ASCII文字| [00-7F] | 
|ひらがな・カタカナ・漢字など|[A0-FE][A0-FE]|
|半角カタカナ|[8E][A0-DF]|

## Shift_JIS

- マルチバイト文字は`2`バイトの固定長で表されます。
- **半角カタカナは1バイトです。**
- 接頭符号ではありません。マルチバイト文字の1バイト目と2バイト目の範囲が重複します。
- **マルチバイト文字の2バイト目がASCII文字および半角カタカナと重複します。**幸い1バイト目は重複しないので、後述するJISよりは扱うのが容易です。
- Microsoftによる独自拡張としてWindows-31J（別名:CP932,SJIS-win）があります。扱える文字が少し増えているようです。

| 文字種 | 表現 |
|:-----|:-----|
|ASCII文字|[00-7F]|
|半角カタカナ|[A1-DF]|
|マルチバイト文字前半|[81-9F][40-FC]|
|マルチバイト文字後半|[E0-EF][40-FC]|

## ISO-2022-JP (JIS)

- マルチバイト文字は`2`バイトの固定長で表されます。
- **半角カタカナは1バイトです。**
- 接頭符号ではありません。マルチバイト文字の1バイト目と2バイト目の範囲が重複します。
- **マルチバイト文字がASCII文字および半角カタカナと重複します。**実際に運用する際にはエスケープシーケンスを用いて「ここからはASCII文字」「ここからは半角カタカナ」「ここからはマルチバイト文字」として表現する必要があります。生成過程で両コードポイントに`20`を足しているため、ASCII文字のうちエスケープシーケンスの先頭バイトとは重複しません。  
- ISO-2022-JPという規格自体には半角カタカナは存在せず、拡張された文字コードでそれぞれ独自に採用されているようです。そのうちの一つとしてMicrosoftのISO-2022-JP-MS(7bit)があります。

| 文字種 | 表現 |
|:-----|:-----|
|ASCII文字|[00-7F]|
|半角カタカナ(7bit)|[21-5F]|
|半角カタカナ(8bit)|[A1-DF]|
|マルチバイト文字|[21-7E][21-7E]|

この文字コードはPHPのソースコードとして有効ではないので、以降は割愛します。（旧来のメールぐらいでしか使われないんじゃないかな…？）

## UTF-16 / UTF-16BE / UTF-16LE

- ASCII文字を含め、**ほとんど全ての文字が`2`バイト固定長で表されます。**
- 2バイトに収まりきらない一部の文字は「サロゲートペア」と呼ばれ、4バイトで表されます。
- 接頭符号ではありません。1バイト目と2バイト目の範囲が重複します。
- ビッグエンディアンとリトルエンディアンの2通りが存在します。これを[BOM](https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%A4%E3%83%88%E3%82%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%83%9E%E3%83%BC%E3%82%AF)を使って表現します。UTF-8と異なり、UTF-16にはBOMが必須です。但し、UTF-16BE/UTF-16LEとして明示する場合には逆にBOMを付加してはなりません。

この文字コードはPHPのソースコードとして有効ではないので、以降は割愛します。

# PHPで扱う上での注目ポイント

UTF-8 / EUC-JP / Shift_JIS についてのみ取り扱います。

## 正規表現の文字クラス

これは全ての文字コードにおいて留意する必要があります。

```php:これはマッチしてしまう
var_dump(preg_match('/[あい]/', 'う')); // int(1)
```

「あ」という文字は `E3` `81` `82` で表され、「う」は `E3` `81` `84` で表されます。PHPは基本的に全ての操作をバイト単位で行うので、このコードはバイトコード **`E3` または `81` または `82`** を探してしまうことになり、マッチするわけです。解決策としてはバイト単位ではなく文字単位であることを明示する必要があります。

```php:UTF-8ではu修飾子を使う
var_dump(preg_match('/[あい]/u', 'う')); // int(0)
```

```php:EUC-JPやShift_JISではmb_eregで代用する
mb_regex_encoding('EUC-JP');
var_dump(mb_ereg('[あい]', 'う')); // bool(false)
```

## ダメ文字

これはShift_JISにおいて留意する必要があります。

- PHPソースコードのパースエラーが起こる
- 正規表現のパースエラーが起こる

これらは全てマルチバイト文字の2バイト目がASCII文字と重複していることにより発生するものです。最も有名なのは`\`で、特別にこれは**5C問題**と呼ばれることもあります。例えば「表」は `95` `5C`と表されますが、`\`の`5C`と重複しています。

```txt:PHPソースコードのパースエラーを回避するためにバックスラッシュを挿入する
$str = '表\';
```

php.iniの設定を変えることによっても対応出来そうです。

- [PHP 5.4の文字エンコーディング設定](http://blog.ohgaki.net/php-5-4)

別の例として、「曽」は `91` `5D` で、`]`の`5D`と重複しています。これを正規表現の文字クラス内で用いる場合は、最初の例同様にマルチバイト対応の関数を使うことで解決出来ます。しかし、PHPソースコードレベルで影響してくる`\`については明示的な対応が必要となってくるのは言うまでもありません。

接頭符号ではない仲間にEUC-JPがいますが、こちらはASCII文字に関しては無問題なので心配する必要はありません。

## `str_replace` `explode` などにおけるマルチバイト文字列に対するマルチバイト文字列による操作

これはEUC-JPおよびShift_JISにおいて留意する必要があります。両者とも接頭符号ではなく、マルチバイト文字列に対して<ins>マルチバイト文字列による</ins>検索/置換を行う場合で問題が発生します。

(EUC-JP)
官: `B4` **`B1`**
庁: **`C4`** `A3` 
営: **`B1` `C4`**

```php:EUC-JPでは「官庁」まで一緒に破壊されてしまう
var_dump(str_replace("営", "休", "官庁の営業")); // 患截の休業
```

`strpos`に対しては`mb_strpos`という関数が存在しますが、`mb_str_replace`や`mb_explode`は存在しません。正規表現用の関数はマルチバイト処理に対応しているので、こちらで代用しましょう。`mb_str_replace`を自作している人を見たことがありますが、手元の環境では組み込みの`mb_ereg_replace`を使ったほうがパフォーマンスは明らかに上でした。

|通常の関数|マルチバイト対応の正規表現用の関数|
|:---|:---|
|`str_replace`|`mb_ereg_replace`|
|`explode`|`mb_split`|

なお、EUC-JPの場合はマルチバイト文字列に対して<ins>ASCII文字列による</ins>検索/置換を行う場合は問題ありません。<ins>ASCII文字列に対して</ins>マルチバイト文字列による、という場合も同様です。この点はShift_JISと差異があります。

```php:\$fromと\$strに何が入っていてもEUC-JPでは正常に動作する
var_dump(str_replace($from, "abc", $str));
```

# もっとラクしようよ！

ここまで見てきて、（EUC-JPはまだ許せるとして）UTF-8以外でPHPコードは書きたくない、というのが一般論だと思います。しかし、例えばもしめちゃくちゃ古いガラケー向けにShift_JISで出力しなければならない、というケースがあったとしても…

**PHPコードはUTF-8で書き、出力時に自動変換させることが出来ます。**

```php:これを最初に実行するだけ
<?php
ob_start(function ($str) {
    return mb_convert_encoding($str, 'SJIS-win', 'UTF-8');
});
```

またShift_JISのCSVファイルを扱うケースもあると思いますが、こういう場合でもパース時および出力時に自動変換をかければ扱いやすくなるはずです。ストリームフィルタを使う方法が最もおすすめです。

- [巨大なSJISのCSVファイルをfgetcsv関数で処理する](http://d.hatena.ne.jp/hnw/20090317)