imu-note
いむたろ
いむたろ
@imutaroh
新卒エンジニア / データ基盤 × AI

文字は3層でできている

文字は、人が見る形のままファイルに入っているわけではない。番号に置き換え、その番号をバイトに詰めて保存している。

層「あ」の場合決めているもの
① 文字あ人が見る形
② コードポイントU+3042Unicode(文字に振った番号の表)
③ バイト列UTF-8:E3 81 82
CP932:82 A0
符号化方式(番号をバイトに詰める規則)

用語解説

  • Unicode:世界中の文字に1つずつ番号を振った共通の表
  • UTF-8:Unicode の番号をバイトに詰める規則。1文字1〜4バイト、英数字は1バイト、日本語は主に3バイト
  • CP932:日本語版 Windows の文字コード。Unicode とは別の体系で、Excel の CSV 保存はいまもこれ
  • Shift_JIS:日本の規格。CP932 はこれに ① ㈱ 髙 などを足した拡張版
  • バイト:8ビットの保存・通信の単位。文字専用ではなく、文字はこれを1〜4個使う

文字の事故は2種類、ずれは3層のどこか

事故症状どの層のずれか
読めない文字化け・読み込みエラー③→②の読み方の規則が、書いた側と違う
一致しないJOIN・名寄せ・列名の照合から漏れる①は同じなのに、②の並びが違う
flowchart LR A["文字列の事故"] --> B{"別の文字に化けている?"} B -- "はい" --> C["書いた側の方式で読み直す"] B -- "いいえ、見た目は同じ" --> D["長さを数える"] D --> E["コードポイントを出す"] E --> F["正体を特定して、その処理を足す"]

読めない事故:文字化けは戻る、置換文字は戻らない

文字化けしたとき、ファイルのバイト列は壊れていない。書いた側と同じ規則で読み直せば戻る。 文字が壊れたと勘違いすることが多いが、使っているレンズが違うだけ。

状態戻せるか
元のファイルが残っている戻せる。正しい方式で読み直す
読めないバイトが置換文字 U+FFFD(�)に置き換わった値戻せない。どの � も EF BF BD で、元の情報が残っていない
  • 名前が同じでも中身が違う。Python の shift_jis は ㈱ を扱えないが、Go の japanese.ShiftJIS は中身が CP932 相当で扱える
  • 定石は、入口で1回だけ UTF-8 に変換し、中では UTF-8 しか扱わないこと
英数字だけのテストでは気づけない

英数字は UTF-8 でも CP932 でも同じバイトになる。文字コードを取り違えていてもテストは通る。テストデータには日本語を入れる。

一致しない事故:見た目が同じで中身が違う

見た目が同じなのに一致しないときは、推測で直さない。数えて、番号を出して、正体を特定してから直す。

原因何が起きるか対処
BOM(Byte Order Mark)ファイル先頭の見えない1文字(UTF-8 では EF BB BF)。先頭の列名だけ照合が外れる先頭の U+FEFF を落とす
CRLF(\r\n)\n だけで区切ると \r が残り、各行の最後の列だけ "shipped\r" になる区切る前に \r\n を \n に置き換える
全角半角「ア」と「ア」、「1」と「1」は別のコードポイントNFKC で正規化する
見えない文字ゼロ幅スペースや BOM は、TrimSpace(BigQuery の TRIM も)で落ちないコードポイントを出して、その文字を指定して落とす
大文字小文字"ABC" == "abc" は false比較の前に小文字にそろえる
  • BOM は「UTF-8 で読め」という名札。ただし付いていないことは何の証拠にもならない。付けてくるのは主に Excel の「CSV UTF-8」保存

正規化:比べる・一致を見るときに大事

正規化は、見た目は同じなのに中身が違う文字列を、1つの形にそろえること。JOIN・名寄せ・重複チェックのように、2つの文字列が同じかを機械が判定するときに必要になる。表示するだけなら要らない。

  • TrimSpace(BigQuery なら TRIM):前後の空白を削る。全角スペースや改行も消えるが、真ん中のスペースと、ゼロ幅スペース・BOM は残る
  • NFKC:形の違う同じ文字をそろえる。半角カナは全角に、全角英数は半角に、㈱ は (株) に、① は 1 になる。長音「ー」とハイフン、大文字小文字、ゼロ幅スペースはそろわない
  • 小文字化(ToLower / LOWER):大文字小文字をそろえる。NFKC はここをやらないので別に足す
"  ABCストア " → TrimSpace → "ABCストア" → NFKC → "ABCストア" → 小文字化 → "abcストア"
元の値は書き換えない

正規化は情報を捨てる(㈱ は (株) に戻らないし、①番棚 と 1番棚 が同じになる)。元の値はそのまま残し、比べるための列を別に作って、そっちだけ正規化する。

Goだとどうなる?

decoded := transform.NewReader(r, japanese.ShiftJIS.NewDecoder())   // CP932 → UTF-8
...
if strings.ContainsRune(f, utf8.RuneError) {   // utf8.RuneError は U+FFFD
    return nil, fmt.Errorf("... invalid Shift-JIS sequence at col %d", ...)
}
  • 1行目:CP932 のバイト列を UTF-8 に読み替える=読み直せば戻る
  • RuneError の検査:デコーダは読めないバイトに出会っても、エラーを返さず黙って U+FFFD に置き換える。だから置換文字が1つでも混ざった値は、テーブルに入れずに止める
Success

文字の事故は「読めない」と「一致しない」の2つだけ。どちらも、3層のどこでずれたかで説明できる。

関連