いまさら聞けない!波ダッシュと全角チルダ問題についてまとめてみた

元々のきっかけは以下のように RubyUTF-8 の波文字を SJIS に変換しようとしたらエラーになってしまいました。

# UTF-8 の 〜 文字を SJIS に変換するとエラーになる
# error: `encode': U+301C from UTF-8 to Windows-31J (Encoding::UndefinedConversionError)
"".encode("SJIS")

これの原因を調べてみたら 波ダッシュと全角チルダ問題 にたどり着いたのでその問題と歴史についてまとめてみました。

諸注意

波ダッシュと全角チルダ問題 に関しては一般的な話なんですが、変換ルールやエンコーディングの指定の仕方などは CRuby での話になります。 他の言語や処理系だと結果が変わるかもしれないので注意してください。 また、この記事でのソースコードはすべて UTF-8 になります。
それでは本題に入っていきましょう。

RubyUTF-8 の波文字()を SJIS に変換するとエラーになる

冒頭にも記述したように元々この問題を調べようとしたきっかけは次のように RubyUTF-8 の波文字()を SJIS に変換しようとするとエラーになってしまうことでした。

# UTF-8 の 〜 文字を SJIS に変換するとエラーになる
# error: `encode': U+301C from UTF-8 to Windows-31J (Encoding::UndefinedConversionError)
"".encode("SJIS")

上記のコードで変換しようとした波文字()は Unicode のコードポイントだと U+301C になるんですが、その文字が SJIS への変換に失敗 しています(ちなみに Ruby では SJISWindows-31Jエイリアスとして定義されています)。

# Ruby で Unicode のコードポイントを表示する
pp "".unpack1("U*").to_s(16)
# => "301c"

SJIS にも波文字自体は存在しているのに不思議ですよね? そもそも波文字とはどういう文字なのでしょうか。 Unicode の波文字について詳しくみてみましょう。

波文字は2つあった!

実は Unicode には波文字を表す文字が2つ存在しています。 それが『波ダッシュ文字(U+301C)』と『全角チルダ文字(U+FF5E)』になります。
最初に Ruby のコードで記述していた波文字は 波ダッシュ文字(U+301C) になります。

文字 意味 Unicode のコードポイント UTF-8 のバイト列
波ダッシュ(WAVE DASH) U+301C \xE3809C
全角チルダ(FULLWIDTH TILDE) U+FF5E \xEFBD9E

この2つの文字は見た目上は似た文字として表示されていると思うのですが、実際には全く異なる文字になります。 試しにここに書いてある文字をバイナリエディタにコピペしてみたり Ruby でコードポイントを取得してみると全然違う文字だと言うことがわかると思います。

# Unicode のコードポイントを出力してみる
# これは波ダッシュ
pp "".unpack1("U*").to_s(16)
# => "301c"

# これは全角チルダ
pp "".unpack1("U*").to_s(16)
# => "ff5e"

このように が波ダッシュ(U+301C) が全角チルダ(U+FF5E)全く異なる文字になります。
この『Unicode には波文字が2種類存在する事』が SJIS に変換できない問題と深く関わってきます。

全角チルダ (U+FF5E) は SJIS に変換できる

先程紹介した Unicode の2つの波文字なのですが 波ダッシュ (U+301C)SJIS に変換するとエラーになることはわかりました。

# これは波ダッシュ
nami = ""
pp nami.unpack1("U*").to_s(16)
# => "ff5e"

# SJIS に変換するとエラーになる
pp nami.encode("SJIS")
# => "\x{8160}"

しかし、実は 全角チルダ (U+FF5E)SJIS に変換する事ができます。

# これは全角チルダ
tilde = ""

pp tilde.unpack1("U*").to_s(16)
# => "ff5e"

# SJIS に変換する事ができる!
pp tilde.encode("SJIS")
# => "\x{8160}"

これは一体どういうことなのでしょうか。

波ダッシュ (U+301C) を Shift_JIS に変換すると…

ここでは変換後のエンコーディングの話をします。
Ruby では SJIS と名前が似ている Shift_JIS というエンコーディングも存在しています。 実は SJIS ではなくて Shift_JIS であれば UTF-8波ダッシュ (U+301C)Shift_JIS に変換することができます。

# これは波ダッシュ
nami = ""

pp nami.unpack1("U*").to_s(16)
# => "301c"

# Shift_JIS に変換する事ができる!
pp nami.encode("Shift_JIS")
# => "\x{8160}"

また逆に UTF-8全角チルダ (U+FF5E)Shift_JIS に変換すると エラー になります。

# これは全角チルダ
tilde = ""

pp tilde.unpack1("U*").to_s(16)
# => "ff5e"

# SJIS に変換するとエラーになる
# error: `encode': U+FF5E from UTF-8 to Shift_JIS (Encoding::UndefinedConversionError)
pp tilde.encode("Shift_JIS")

Ruby の変換ルールとしては以下のようになっています。

変換前の UTF-8 の文字 SJIS Shift_JIS
波ダッシュ (U+301C) 変換できない 変換できる
全角チルダ (U+FF5E) 変換できる 変換できない

次は SJISShift_JIS の違いについて調べてみましょう。

Ruby における SJISShift_JIS の違い

ここでは Ruby における SJISShift_JIS の違いについて簡単に解説します。
まず Shift_JIS はその名の通り文字コードShift_JIS のことを指しています。 Shift_JIS とは日本語を含む文字列を表現するために用いられる文字コードになります。 また Shift_JISJIS X 0208 として標準化されています。

Ruby ではこの文字コードShift_JIS として利用できます。

次に SJIS なのですが Rubyエンコーディングにおいて SJISCP932 Windows-31J と同じエンコーディングとして定義されています。 Windows-31J とは MicrosoftShift_JIS を独自拡張したエンコーディングになります。
例えば という文字は Shift_JIS には存在しませんが Windows-31J には存在する文字になります。

# Windows-31J に変換可能
pp "".encode("Windows-31J")   # => "\x{8740}"

# SJIS や CP932 も同様
pp "".encode("SJIS")   # => "\x{8740}"
pp "".encode("CP932")   # => "\x{8740}"

# Shift_JIS には変換できない
# error: U+2460 from UTF-8 to Shift_JIS (Encoding::UndefinedConversionError)
pp "".encode("Shift_JIS")

なので厳密に言うと SJISShift_JIS異なるエンコーディング になります。

まとめると Ruby において SJISShift_JIS は以下のような関係になっています。

より詳しい違いが知りたい人は以下を参照してください。

以降は SJIS という表記だとややこしいので CP932 と表記し『 CP932Shift_JIS 』について解説していきます。

JIS X 0208 の波文字とは

ここでは Shift_JISCP932 の規格である JIS X 0208 の話をします。 実は JIS X 0208 で定義されている波文字は Unicode とは違って『波ダッシュ文字(\x8160)だけ』が存在しています。 (JIS X 0208 を更に拡張した JIS X 0213 には全角チルダも定義されているんですがここでは一旦置いておきます。
エンコーディングの波文字の情報は以下のようになります。

文字 Unicode のコードポイント UTF-8 のバイト列 JIS X 0208 のバイト列
波ダッシュ(WAVE DASH) U+301C \xE3809C \x8160
全角チルダ(FULLWIDTH TILDE) U+FF5E \xEFBD9E ない

なので UTF-8波ダッシュShift_JIS に変換すると Shift_JIS波ダッシュに変換されるのは期待する挙動と言えます。

# 波ダッシュを Shift_JIS に変換すると Shift_JIS の波ダッシュになる
p "\u301C".encode("Shift_JIS")   # => "\x{8160}"

では、なぜ UTF-8 の波文字を CP932 に変換するときに『対応する文字がある波ダッシュがエラー』になって『対応する文字がない全角チルダがエラー』になるのでしょうか。

# 全角チルダを CP932 に変換するとなぜか JIS X 0208 の波ダッシュになる
p "\uFF5E".encode("CP932")     # => "\x{8160}"

# 波ダッシュを CP932 に変換するとJIS X 0208 の波ダッシュは存在するのにエラーになる
# error: `encode': U+301C from UTF-8 to Windows-31J (Encoding::UndefinedConversionError)
p "\u301C".encode("CP932")   # => "\x{8160}"

この疑問を解消するためにまず JIS X 0208波ダッシュ、つまり『 CP932Shift_JIS から UTF-8 に変換するとどうなるのか』を試してみましょう。

JIS X 0208波ダッシュUTF-8 に変換するとどうなる?

今度は逆に JIS X 0208波ダッシュUTF-8 に変換するとどうなるのか見てみましょう。 実際に RubyShift_JISCP932 から波ダッシュUTF-8 に変換してみます。
Ruby で変換する場合は JIS X 0208波ダッシュ文字をバイト列で定義し String#encode に変換元となるエンコーディングの情報として CP932Shift_JIS をそれぞれ指定して UTF_8 に変換してみます。

# CP932 でも Shift_JIS でもバイト列は同じ
jis_nami = "\x81\x60"

# CP932 -> UTF_8 に変換すると全角チルダになる
pp jis_nami.encode("UTF-8", "CP932").unpack1("U*").to_s(16)
# => "ff5e"

# Shift_JIS -> UTF_8 に変換すると波ダッシュになる
pp jis_nami.encode("UTF-8", "Shift_JIS").unpack1("U*").to_s(16)
# => "301c"

このように Shift_JIS -> UTF_8 は『同じ文字への変換』になっています。 しかし CP932 -> UTF_8 は『異なる文字への変換』になっていることがわかります。

文字コード 変換前 UTF-8 へ変換
CP932 波ダッシュ 全角チルダ
Shift_JIS 波ダッシュ 波ダッシュ

この『 CP932波ダッシュUTF-8 に変換したときに誤った文字になってしまうこと』がいわゆる 波ダッシュ問題 として一般的に扱われている問題になります。
では、なぜこのような変換になってしまったのでしょうか。 Unicode の歴史を調べてみましょう。

Unicode波ダッシュの例示字形が間違っていた

結論からいうと Unicode 7.0 で定義されていた波ダッシュ例示字形 が間違った形で記載されてしまっていたのが要因になっているようです。
どういうことかと言うと現在の Unicode では波ダッシュも全角チルダ のように 上がって下がる ような字形になります。 これは JIS X 0208 も同様の字形になっています。
しかし Unicode 7.0 では波ダッシュが以下のように 下がって上がる という文字として例示字形が記載されていました。

文字コード 波ダッシュ 全角チルダ
Unicode 7.0
※これが間違い
Unicode 8.0
JIS X 0208

実際に Unicode のコードチャートにも以下のように記載されています。

Unicode 7.0

Unicode 8.0

WAVE DASH の例示字形が異なる形になっているのがわかると思います。 これが CP932 -> UTF-8 に変換した際に 波ダッシュ -> 全角チルダ となってしまっている要因になっています。
この Unicode 7.0Unicode 8.0波ダッシュの例示字形の問題に関しては以下の記事で詳しく解説されているので気になる人は読んでみるとよいと思います。

問題は逆だった

ここからは仮説になってくるのですが先程説明したように JIS X 0208波ダッシュUnicode 7.0波ダッシュでは字形が異なっていました。 なのでそのまま JIS X 0208波ダッシュUTF-8波ダッシュに変換してしまうと 文字としては同じ なのですが 見た目としては違う文字 に見えてしまいます。
この問題を回避するために Windows では JIS X 0208波ダッシュUTF-8 に変換する時に『 JIS X 0208波ダッシュと同じ字形であった全角チルダに変換する』というルールができたのではないかと考えられます。 これにより Windows で使われている CP932 は『波ダッシュ -> 全角チルダ』という変換になり、逆に JIS X 0208 と同等である Shift_JIS はそのままの文字である『波ダッシュ -> 波ダッシュ』になっているのではなかろうかと思います。

文字コード 変換前 UTF-8 へ変換
CP932 波ダッシュ 全角チルダ
Shift_JIS 波ダッシュ 波ダッシュ


逆に UTF-8 -> CP932 と変換するときも同様に『 全角チルダ -> 波ダッシュ 』となり UTF-8 -> Shift_JIS だと『 波ダッシュ -> 波ダッシュ 』になっているのだと予想しています。

文字コード 変換前 CP932 へ変換 Shift_JIS へ変換
UTF-8 波ダッシュ エラー 波ダッシュ
UTF-8 全角チルダ 波ダッシュ エラー

まとめ

文字 意味 Unicode のコードポイント UTF-8 のバイト列
波ダッシュ(WAVE DASH) U+301C \xE3809C
全角チルダ(FULLWIDTH TILDE) U+FF5E \xEFBD9E
変換前の文字コード 変換後の文字コード 変換前の文字 変換後の文字
UTF-8 CP932 波ダッシュ(U+301C) エラー
UTF-8 CP932 全角チルダ(U+FF5E) 波ダッシュ(\x8160)
UTF-8 Shift_JIS 波ダッシュ(U+301C) 波ダッシュ(\x8160)
UTF-8 Shift_JIS 全角チルダ(U+FF5E) エラー
CP932 UTF-8 波ダッシュ(\x8160) 全角チルダ(U+FF5E)
Shift_JIS UTF-8 波ダッシュ(\x8160) 波ダッシュ(U+301C)

参照

余談:RubyUnicode波ダッシュSJIS波ダッシュに変換する

以下のように #encodefallback オプションで制御する事が可能です。

pp "".encode("SJIS", fallback: { "\u301C" => "\x81\x60".force_encoding("SJIS") })

別解としては #tr波ダッシュを全角チルダに変換してから #encode するやり方も考えられます。

pp "".tr("\u301C", "\uFF5E").encode("SJIS")

余談: iconv の変換ルール

iconv だと次のような変換ルールとなっています。

変換前の文字コード 変換後の文字コード 変換前の文字 変換後の文字
UTF-8 CP932 波ダッシュ(U+301C) 波ダッシュ(U+301C) ※これが Ruby と異なる
UTF-8 CP932 全角チルダ(U+FF5E) 波ダッシュ(\x8160)
UTF-8 Shift_JIS 波ダッシュ(U+301C) 波ダッシュ(\x8160)
UTF-8 Shift_JIS 全角チルダ(U+FF5E) エラー
CP932 UTF-8 波ダッシュ(\x8160) 全角チルダ(\xEFBD9E)
Shift_JIS UTF-8 波ダッシュ(\x8160) 波ダッシュ(\xE3809C)

以下、実行ログ。

# utf-8 の波ダッシュ -> cp932 は変換できる
$ echo -n "〜" | iconv -f utf-8 -t cp932 | od -tx1 -An
 81 60

# utf-8 の全角チルダ -> cp932 は変換できる
$ echo -n "~" | iconv -f utf-8 -t cp932 | od -tx1 -An
 81 60
# utf-8 の波ダッシュ -> shift_jis は変換できる
$ echo -n "〜" | iconv -f utf-8 -t shift_jis | od -tx1 -An
 81 60

# utf-8 の全角チルダ -> shift_jis は変換できない
$ echo -n "~" | iconv -f utf-8 -t shift_jis | od -tx1 -An
iconv: 位置 0 に不正な入力シーケンスがあります
# cp932 -> utf-8 は全角チルダ
$ echo -n $'\x81\x60' | iconv -f cp932 -t utf-8 | od -tx1 -An
 ef bd 9e

# shift_jis -> utf-8 は波ダッシュ
$ echo -n $'\x81\x60' | iconv -f shift_jis -t utf-8 | od -tx1 -An
 e3 80 9c

UTF-8 -> CP932 の変換だけ Ruby と異なっています。