こんにちは、おうどんです🍜
取引先からもらった CSV を C# で読んだら、商品名が全部『���ǂ�』になりました
呪文かな
呪文です。しかも、エラーは出てません
エラーが出ないのが、文字化けのいちばん困るところですね
(会話は説明用の架空の場面です)
うどん屋さんの厨房でたとえます。
ホールの店員さんは、注文を言葉ではなく番号で厨房に伝えます。
「12番」がきつね、「34番」がたぬき。
厨房は、壁に貼った番号表を見て、うどんを作ります。
ところが、ある日、支店から来た応援の店員さんが、支店の番号表で注文を通しました。
本店の厨房は、本店の番号表で読みます。
きつねを頼んだお客さんに、ざるそばが出ました。
読者「うどん屋なのに、そば」
番号表がずれると、そういうことが起きます。
コンピューターの文字コードは、まさにこの番号表なんです。
今日のチェックリスト(初心者はここだけでも)
- ファイルを読むとき・書くときは、文字コード(
Encoding)を自分で指定する - 文字化けしたら、まず「書いた側の文字コード」と「読んだ側の文字コード」を確かめる
- 文字数とバイト数は別もの。上限が「バイト」で決まっている場所では、バイトで数える
文字コードは、文字と番号の対応表
コンピューターは、文字そのものを覚えられません。覚えられるのは数字だけです。
だから「この文字は、この番号」という対応表を決めて、文字を番号(バイト)にして保存します。この対応表と、番号をバイトに並べるルールをまとめて、文字コードと呼びます。
用語を3つだけ、先に置いておきます。
- バイト:コンピューターが保存する数字の単位。1バイトは 0〜255 の数字1つ。本文では
E3のような16進数(0〜9 と A〜F で数える書き方)で書きます - Unicode:世界中の文字に背番号を振った表。背番号は
U+3042(あ)のように書きます。この背番号をコードポイントと呼びます - UTF-8:Unicode の背番号を、1〜4バイトに並べて保存するルール。いまの主流です
- Shift_JIS:日本語用に昔から使われてきた文字コード。Windows の日本語環境で作られた古いファイルや、古いシステムの CSV でまだ出会います
3つって言いましたよね。4つあります
Unicode と UTF-8 は、背番号と背番号の書き方なので、1つと数えてください
数え方が雑なのは、このあとの話にも出てくる伏線です。(たぶん)
.NET の文字エンコーディングの解説では、Unicode のコードポイントは 0 から U+10FFFF(10進数で 1,114,111)までの整数で、文字や記号、絵文字、改行のような動作に割り当てられている、と説明されています。
つまり、Unicode が「きつねは何番か」を決める番号表で、UTF-8 や Shift_JIS は「その番号を伝票にどう書くか」の書き方、という関係です。
番号表は1枚でも、伝票の書き方が何種類もある、ということです。
伝票、統一してほしい
してほしいです。本当に
いまは UTF-8 にそろってきていますが、古いシステムの伝票は、まだ現役なんですよね。
同じ「うどん」でも、文字コードが違えばバイトが違う
C# で、同じ文字を UTF-8 と Shift_JIS でバイトにして並べてみます。
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var sjis = Encoding.GetEncoding("shift_jis");
foreach (var s in new[] { "A", "あ", "うどん", "🍜" })
{
Console.WriteLine($"{s} UTF-8: {Hex(Encoding.UTF8.GetBytes(s))} Shift_JIS: {Hex(sjis.GetBytes(s))}");
}
A UTF-8: 41 Shift_JIS: 41
あ UTF-8: E3 81 82 Shift_JIS: 82 A0
うどん UTF-8: E3 81 86 E3 81 A9 E3 82 93 Shift_JIS: 82 A4 82 C7 82 F1
🍜 UTF-8: F0 9F 8D 9C Shift_JIS: 3F 3F
(今回の検証環境:.NET 10.0.13、C#。実行したプログラムから該当部分を抜き出しています。Hex() はバイトを16進数で並べる自作の関数で、中身は string.Join(" ", b.Select(x => x.ToString("X2"))) です。1行目の RegisterProvider は、あとの章で説明します)
見えてくることが4つあります。
- 「A」は、どちらでも
41。英数字は、多くの文字コードで同じ番号です - 「あ」は、UTF-8 だと3バイト、Shift_JIS だと2バイト。同じ文字なのに、番号も長さも違う
- 「うどん」も同じ。UTF-8 で9バイト、Shift_JIS で6バイト
- 「🍜」は、Shift_JIS では
3F 3F。3Fは「?」の番号です。Shift_JIS の表に🍜がないので、はてなに置き換えられました
うどんの絵文字が、はてなはてなに
支店のメニューに、ラーメンがなかったんだね
🍜はどう見てもラーメンですが、おうどんはこのブログでずっとうどんとして使っています。見逃してください。
UTF-8 の決まりを書いた RFC 3629(インターネットの技術仕様の文書)では、背番号の範囲ごとに 1〜4 バイトの並べ方が決まっていて、US-ASCII(英数字と記号の基本の表)の文字は1バイトのまま、と定められています。「A」がどちらでも 41 だったのは、この1バイトの範囲だからです。
文字化けは、書いた表と読んだ表のすれ違い
冒頭の「���ǂ�」の正体を、作って確かめます。
string menu = "きつねうどん";
byte[] u8 = Encoding.UTF8.GetBytes(menu);
byte[] sj = sjis.GetBytes(menu);
Console.WriteLine("UTF-8 で書いて Shift_JIS で読む: " + sjis.GetString(u8));
Console.WriteLine("Shift_JIS で書いて UTF-8 で読む: " + Encoding.UTF8.GetString(sj));
Console.WriteLine("Shift_JIS で書いて Shift_JIS で読む: " + sjis.GetString(sj));
UTF-8 で書いて Shift_JIS で読む: 縺阪▽縺ュ縺・←繧・
Shift_JIS で書いて UTF-8 で読む: ���˂��ǂ�
Shift_JIS で書いて Shift_JIS で読む: きつねうどん
(実行したプログラムから該当部分を抜き出しています)
文字化けには、2つの顔があります。
- UTF-8 のファイルを Shift_JIS で読むと、「縺阪▽縺ュ」のような、それっぽい漢字の列になる。読めてしまうので、気づくのが遅れがちです
- Shift_JIS のファイルを UTF-8 で読むと、「�」がたくさん出る。この「�」は U+FFFD という置き換え文字で、「このバイトは UTF-8 として読めませんでした」の印です
.NET の解説にも、Encoding.UTF8.GetString は UTF-8 として正しくないバイトの並びを U+FFFD に置き換える、と書かれています。
冒頭の CSV、�だらけでした。ということは
Shift_JIS で書かれた CSV を、UTF-8 で読んだんだね。支店の伝票を、本店の番号表で読んだ
逆に、「縺阪▽縺ュ」が出たら、UTF-8 で書かれたものを Shift_JIS で読んでいる、と見当がつきます。
文字化けの見た目は、どの表とどの表がすれ違ったかの手がかりなんです。
文字化けで止めたいときは、例外を出す設定にする
エラーが出ないまま「�」に置き換わるのが困るなら、読めないバイトで例外を出す UTF-8 を使います。
var strict = new UTF8Encoding(false, true);
try
{
strict.GetString(sj);
}
catch (DecoderFallbackException e)
{
Console.WriteLine(e.GetType().Name + ": " + e.Message);
}
DecoderFallbackException: Unable to translate bytes [82] at index 0 from specified code page to Unicode.
(実行したプログラムから該当部分を抜き出しています)
new UTF8Encoding(false, true) の2つ目の true が、「読めないバイトがあったら例外を出す」という指定です。.NET の解説でも、セキュリティが大事なアプリでは置き換えではなく例外にする方法がよく使われる、と紹介されています。
読者「黙って�にされるより、止まってくれたほうがいい」
伝票が読めないのに、とりあえず何か作って出す厨房より、「この伝票読めません」と言ってくれる厨房のほうが、結局お客さんは助かります。
🔰 ここまで読めば今日から困らない
文字化けしたら、まずこの3つです。
- 「�」が出たら、Shift_JIS など UTF-8 以外で書かれたものを UTF-8 で読んでいる可能性が高い
- 「縺阪▽縺ュ」のような漢字の列なら、UTF-8 で書かれたものを Shift_JIS で読んでいる可能性が高い
- 読むときも書くときも、
Encodingを指定する
C# で Shift_JIS のファイルを読む最小の形はこうです(.NET Core・.NET 5 以降の場合)。
using System.Text;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
string text = File.ReadAllText("menu.csv", Encoding.GetEncoding("shift_jis"));
(上は説明用の形で、このまま実行はしていません。同じ書き方で File.ReadAllText(p4, sjis) を実行して読めたことは、次の章の結果にあります)
RegisterProvider って何ですか。いつもの呪文ですか
支店の番号表を、本店の壁に貼る作業
まさにそれです。次の章で、なぜ貼らないといけないのかを見ます。
Python で同じ悩みにぶつかった人は、実践編のPythonでUnicodeDecodeError: ‘cp932’が出る原因と対処もどうぞ。
ここから先は中級者向け:C# で文字コードを指定するときの落とし穴
落とし穴1:.NET では、Shift_JIS が最初から使えない
RegisterProvider を呼ばずに Shift_JIS を取ろうとすると、こうなります。
try
{
var sj0 = Encoding.GetEncoding("shift_jis");
Console.WriteLine("取れた: " + sj0.WebName);
}
catch (Exception e)
{
Console.WriteLine(e.GetType().Name + ": " + e.Message);
}
Console.WriteLine("Encoding.Default: " + Encoding.Default.WebName);
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var sjis = Encoding.GetEncoding("shift_jis");
Console.WriteLine($"登録後: WebName={sjis.WebName} CodePage={sjis.CodePage} EncodingName={sjis.EncodingName}");
ArgumentException: 'shift_jis' is not a supported encoding name. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method. (Parameter 'name')
Encoding.Default: utf-8
登録後: WebName=shift_jis CodePage=932 EncodingName=Japanese (Shift-JIS)
(実行したプログラムから該当部分を抜き出しています)
登録前は ArgumentException。登録したら取れました。
CodePagesEncodingProvider のリファレンスには、Windows の .NET Framework はたくさんの文字コードを使えるのに対して、.NET Core が使えるのは ASCII・ISO-8859-1・UTF-7・UTF-8・UTF-16・UTF-32 などに限られ、それ以外のコードページ(文字コードの番号)は使えない、と書かれています。使うには CodePagesEncodingProvider.Instance を Encoding.RegisterProvider に渡す、という手順です。
昔の .NET Framework のコードを .NET 10 に持ってきたら、Shift_JIS のところで落ちた。あるあるだと思う
それ、おうどんさんの体験ですか
……いや、あるあるだと思う、という予想です
正直でよろしい。登録は、アプリの起動時に1回やっておけば足ります。
Encoding.Default が utf-8 だったのも見ておいてください。今回の検証環境(.NET 10、日本語の Windows)では、Encoding.Default は Shift_JIS 系ではなく UTF-8 でした。「Default なら Windows の日本語の設定になるはず」と思いこんでいると、ここでもずれます。
落とし穴2:Encoding.UTF8 で書くと、BOM が付く
BOM(バイト・オーダー・マーク)は、ファイルの先頭に付ける目印です。UTF-8 の BOM は EF BB BF の3バイト。
同じ「うどん」を、書き方を変えて4つのファイルに書き、中身のバイトを並べました。
File.WriteAllText(p1, "うどん");
File.WriteAllText(p2, "うどん", Encoding.UTF8);
File.WriteAllText(p3, "うどん", new UTF8Encoding(false));
File.WriteAllText(p4, "うどん", sjis);
引数なし E3 81 86 E3 81 A9 E3 82 93
Encoding.UTF8 EF BB BF E3 81 86 E3 81 A9 E3 82 93
new UTF8Encoding(false) E3 81 86 E3 81 A9 E3 82 93
shift_jis 82 A4 82 C7 82 F1
(実行したプログラムから該当部分を抜き出しています。表示の左側は書き方の名前、右側はファイルのバイトです)
- 引数なしの
File.WriteAllTextは、UTF-8 で BOM なし Encoding.UTF8を渡すと、先頭にEF BB BFが付く- BOM なしで書きたいなら
new UTF8Encoding(false)を渡す
File.WriteAllText のリファレンスにも、引数なしの版は BOM のない UTF-8 を使い、BOM を付けたいなら Encoding.UTF8 を渡す版を使う、とあります。Encoding.UTF8 のリファレンスのほうにも、このプロパティが返すものは BOM を付ける、BOM なしにしたいなら UTF8Encoding のコンストラクターを呼ぶ、と書かれています。
『UTF-8 でお願い』と言われたから、Encoding.UTF8 って書きました
丁寧に、伝票の頭に『UTF-8 です』のハンコまで押したんだね
そのハンコを、受け取る側のシステムが知らないと、先頭の項目名に見えない3バイトがくっつきます。
「1列目の名前だけ一致しない」「先頭の行だけ数字にならない」が起きたら、BOM を疑ってください。相手が BOM 付きを求めているのか、BOM なしを求めているのかを、先に確かめるのが確実です。
読者「『UTF-8』の一言じゃ、注文が足りなかった」
BOM あり・なし、大盛り・並盛りみたいに聞かないといけないんですね
BOM はトッピングです。乗せるかどうかは、お客さんに聞きましょう。
落とし穴3:ReadAllText は、BOM がないと UTF-8 として読む
さっき書いた Shift_JIS のファイル(d.txt)を、文字コードを指定せずに読むとこうなりました。
Console.WriteLine("ReadAllText(d.txt) : " + File.ReadAllText(p4));
Console.WriteLine("ReadAllText(d.txt, shift_jis): " + File.ReadAllText(p4, sjis));
Console.WriteLine("ReadAllText(b.txt) Length : " + File.ReadAllText(p2).Length);
ReadAllText(d.txt) : ���ǂ�
ReadAllText(d.txt, shift_jis): うどん
ReadAllText(b.txt) Length : 3
(実行したプログラムから該当部分を抜き出しています)
File.ReadAllText のリファレンスによると、このメソッドは BOM があれば UTF-8・UTF-16・UTF-32 を自動で見分けます。
裏を返すと、BOM のない Shift_JIS のファイルは見分けられません。今回の検証では UTF-8 として読まれて、冒頭の新人さんと同じ「���ǂ�」になりました。shift_jis を指定すれば「うどん」です。
3行目は、BOM 付きの b.txt を指定なしで読んだ文字数です。3(う・ど・ん)で、BOM は文字として残っていませんでした。
BOM のハンコがあれば、厨房が勝手に番号表を選んでくれる
ハンコのない支店の伝票は?
本店の表で読まれて、ざるそばが出る
だから、支店の伝票なら「支店の表で」と、こちらから言ってあげます。それが Encoding の引数です。
落とし穴4:文字数とバイト数は別もの
「20文字まで」と「20バイトまで」は、日本語だと大違いです。
string order = "天ぷらうどん大盛り";
int limit = 20;
Console.WriteLine($"{order}: {order.Length}文字 UTF-8={Encoding.UTF8.GetByteCount(order)}バイト Shift_JIS={sjis.GetByteCount(order)}バイト 上限{limit}バイトに UTF-8 で入る={Encoding.UTF8.GetByteCount(order) <= limit}");
天ぷらうどん大盛り: 9文字 UTF-8=27バイト Shift_JIS=18バイト 上限20バイトに UTF-8 で入る=False
(実行したプログラムから該当部分を抜き出しています)
9文字で、UTF-8 なら 27 バイト、Shift_JIS なら 18 バイト。
上限がバイトで決まっている場所(バイト数で長さを決めたデータベースの列、固定長のファイル、通信の電文など)では、Shift_JIS のころは入っていたデータが、UTF-8 に変えたとたんに入らなくなる、ということが起きます。
Shift_JIS から UTF-8 に移行するだけで、天ぷらがはみ出す
丼は同じ大きさなのに、天ぷらが1.5倍に膨らむ
string.Length はバイト数ではありません。バイトで数えたいときは Encoding.GetByteCount() を使います。そして Length が何を数えているかは、上級者向けの章でもう一度出てきます。
そのまま使える:文字コードの確認ツール
もらったファイルが何の文字コードか分からないとき用の、確認ツールです。
「推測」ではなく、BOM → UTF-8 として正しいか → Shift_JIS として読めるか、の順に、例外を出す設定で厳しく確かめます。
// 文字コードの確認ツール(BOM → UTF-8 → Shift_JIS の順に、厳しく読めるかを確かめる)
using System;
using System.IO;
using System.Linq;
using System.Text;
Console.OutputEncoding = new UTF8Encoding(false); // 画面表示を UTF-8 にする
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // Shift_JIS(932)を使えるようにする
var strictUtf8 = new UTF8Encoding(false, true); // 壊れたバイトで例外を出す UTF-8
var strictSjis = Encoding.GetEncoding(932,
EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
// --- 確かめるためのサンプルを作る ---
string dir = Path.Combine(AppContext.BaseDirectory, "samples");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "utf8.txt"), "きつねうどん");
File.WriteAllText(Path.Combine(dir, "utf8bom.txt"), "きつねうどん", Encoding.UTF8);
File.WriteAllText(Path.Combine(dir, "sjis.txt"), "きつねうどん", Encoding.GetEncoding(932));
File.WriteAllText(Path.Combine(dir, "utf16.txt"), "きつねうどん", Encoding.Unicode);
File.WriteAllText(Path.Combine(dir, "ascii.txt"), "kake udon");
byte[] cut = Encoding.UTF8.GetBytes("きつねうどん");
File.WriteAllBytes(Path.Combine(dir, "broken.txt"), cut.Take(cut.Length - 1).ToArray());
// --- 確かめる ---
foreach (string path in Directory.GetFiles(dir).OrderBy(p => p))
{
byte[] b = File.ReadAllBytes(path);
string head = string.Join(" ", b.Take(4).Select(x => x.ToString("X2")));
Console.WriteLine($"{Path.GetFileName(path),-12} 先頭={head,-12} → {Guess(b)}");
}
string Guess(byte[] b)
{
// BOM(ファイルの先頭の目印)を先に見る。UTF-32 は扱わない
if (b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) return "UTF-8(BOM あり)";
if (b.Length >= 2 && b[0] == 0xFF && b[1] == 0xFE) return "UTF-16 LE(BOM あり)";
if (b.Length >= 2 && b[0] == 0xFE && b[1] == 0xFF) return "UTF-16 BE(BOM あり)";
if (b.All(x => x < 0x80)) return "ASCII だけ(UTF-8 でも Shift_JIS でも同じバイト)";
if (CanDecode(strictUtf8, b)) return "UTF-8(BOM なし)として正しい";
if (CanDecode(strictSjis, b)) return "UTF-8 ではない。Shift_JIS(932)としては読める";
return "UTF-8 でも Shift_JIS でもない(別の文字コードか、壊れている)";
}
static bool CanDecode(Encoding enc, byte[] b)
{
try { enc.GetString(b); return true; }
catch (DecoderFallbackException) { return false; }
}
ascii.txt 先頭=6B 61 6B 65 → ASCII だけ(UTF-8 でも Shift_JIS でも同じバイト)
broken.txt 先頭=E3 81 8D E3 → UTF-8 でも Shift_JIS でもない(別の文字コードか、壊れている)
sjis.txt 先頭=82 AB 82 C2 → UTF-8 ではない。Shift_JIS(932)としては読める
utf16.txt 先頭=FF FE 4D 30 → UTF-16 LE(BOM あり)
utf8.txt 先頭=E3 81 8D E3 → UTF-8(BOM なし)として正しい
utf8bom.txt 先頭=EF BB BF E3 → UTF-8(BOM あり)
(今回の検証環境:.NET 10.0.13。dotnet run で実行した出力そのままです。broken.txt は、UTF-8 の「きつねうどん」の最後の1バイトを切り落としたファイルです)
長いです
半分はサンプル作りだから。本体は Guess の10行くらい
使うときの注意が3つあります。
- 「Shift_JIS としては読める」は、Shift_JIS だという証明ではありません。別の文字コードのファイルが、たまたまエラーなしで通ることもありえます。最後は中身を目で見て確かめてください
- 短いファイルや、英数字ばかりのファイルは見分けがつきません。ASCII だけなら、どちらで読んでも同じなので困りませんが
- UTF-32 と、BOM のない UTF-16 は見ていません。ふだん出会うことが少ないので省きました。必要なら
Guessに足してください
Console.OutputEncoding の行は、画面に日本語を正しく出すためのものです。画面(コンソール)にも文字コードがあり、そこがずれると、ファイルは正しいのに表示だけ化ける、ということが起きます。番号表は、厨房だけでなく、レジにも貼ってあるわけです。
運用前のチェックリスト
- ファイルを読む・書く場所すべてで
Encodingを指定している(File.ReadAllText・File.WriteAllText・StreamReader・StreamWriterなど) - Shift_JIS を使うなら、起動時に
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)を呼んでいる - UTF-8 で書くとき、相手が BOM あり・なしのどちらを求めているか確かめた
- 読めないバイトを黙って「�」にしてよいか決めた。だめなら例外を出す設定にしている
- 長さの上限がバイトで決まっている場所では、
GetByteCount()で数えている
ここから先は上級者向け。読み飛ばしてもOKです:Length の正体と、Shift_JIS という名前の幅
C# の string は UTF-16 で、Length は char の数
C# の string の中身は、UTF-8 でも Shift_JIS でもなく、UTF-16 です。
.NET の文字エンコーディングの解説には、string は論理的には 16 ビットの値(char)の並びで、string.Length は char の数を返す、と書かれています。
foreach (var s in new[] { "きつねうどん", "🍜" })
{
Console.WriteLine($"{s} Length={s.Length} Rune={s.EnumerateRunes().Count()} 文字(見た目)={new StringInfo(s).LengthInTextElements} UTF-8={Encoding.UTF8.GetByteCount(s)}バイト Shift_JIS={sjis.GetByteCount(s)}バイト");
}
きつねうどん Length=6 Rune=6 文字(見た目)=6 UTF-8=18バイト Shift_JIS=12バイト
🍜 Length=2 Rune=1 文字(見た目)=1 UTF-8=4バイト Shift_JIS=2バイト
(実行したプログラムから該当部分を抜き出しています)
🍜は、見た目は1文字なのに Length は 2。
同じ解説によると、U+0000 から U+FFFF までの範囲(BMP、基本多言語面)の文字は char 1つで表せますが、それより大きい背番号の文字は char 2つの組(サロゲートペア)で表します。🍜の背番号は U+1F35C なので、2つ必要でした。
- 背番号(Unicode のスカラー値)の数を数えるなら
EnumerateRunes()(Runeは .NET Framework にはない型) - 見た目の1文字を数えるなら
StringInfo(.NET では書記素クラスターをテキスト要素と呼ぶ)
Length、信じてたのに
Length はうそをついてない。数えている単位が、丼じゃなくてお箸だっただけ
🍜を食べるのに、お箸は2本要ります。そういうことにしておきましょう。
ちなみに、Shift_JIS の列が 2バイト なのは、さっきの 3F 3F です。2つの char が、それぞれ「?」になったように見えます。表にない文字は、バイト数を数えた時点で、もう別のものになっています。
Substring で、サロゲートペアを切ってしまう
Length が char の数なので、Substring も char の単位で切ります。
string bowl = "🍜うどん";
string half = bowl.Substring(0, 1);
Console.WriteLine($"Substring(0, 1) = U+{(int)half[0]:X4} UTF-8: {Hex(Encoding.UTF8.GetBytes(half))}");
Console.WriteLine("Substring(0, 3) = " + bowl.Substring(0, 3));
var e1 = StringInfo.GetTextElementEnumerator(bowl);
e1.MoveNext();
Console.WriteLine("最初の1文字(StringInfo) = " + e1.GetTextElement());
Substring(0, 1) = U+D83C UTF-8: EF BF BD
Substring(0, 3) = 🍜う
最初の1文字(StringInfo) = 🍜
(実行したプログラムから該当部分を抜き出しています)
「先頭の1文字」のつもりで Substring(0, 1) をすると、🍜の前半 U+D83C だけが取れます。これを UTF-8 にすると EF BF BD。U+FFFD(置き換え文字)のバイトです。
.NET の解説でも、サロゲートペアを分けてしまう Substring は正しくない UTF-16 の文字列を作る例として挙げられています。
読者「表示名を10文字で切ったら、最後の絵文字が�になる、ってこれか」
切る位置を StringInfo で決めれば、🍜は丸ごと1つで取れます。
画面に出す文字を切るなら StringInfo、バイトの上限で切るなら GetByteCount() で確かめながら、と、目的で道具を変えます。
丼を半分に割ったら、どっちにもうどんが入ってない
割らないでください
.NET の「shift_jis」は、コードページ 932
さっき、登録後の GetEncoding("shift_jis") は CodePage=932 でした。
932 は、Windows の日本語のコードページの番号です。名前は「shift_jis」でも、中身は 932 の表。丸数字や、はしご高(髙)のような文字も試してみます。
foreach (var s in new[] { "①", "髙", "~", "〜", "🍜" })
{
byte[] b = sjis.GetBytes(s);
string back = sjis.GetString(b);
Console.WriteLine($"{s} U+{char.ConvertToUtf32(s, 0):X4} -> {Hex(b)} -> {back} U+{char.ConvertToUtf32(back, 0):X4}");
}
① U+2460 -> 87 40 -> ① U+2460
髙 U+9AD9 -> FB FC -> 髙 U+9AD9
~ U+FF5E -> 81 60 -> ~ U+FF5E
〜 U+301C -> 3F -> ? U+003F
🍜 U+1F35C -> 3F 3F -> ?? U+003F
(実行したプログラムから該当部分を抜き出しています)
丸数字の①も、はしご高の髙も、行って帰ってこられました。
気をつけたいのは、4行目です。見た目がそっくりな「~」(U+FF5E、全角チルダ)と「〜」(U+301C、波ダッシュ)のうち、U+301C のほうは「?」になりました。見た目が同じでも背番号が違う文字で、今回の 932 の表では U+FF5E だけが 81 60 に対応していました。
同じことを Python 3.13.1 でも試すと、結果が違いました。
for ch in ["①", "~", "〜"]:
row = [f"{ch} U+{ord(ch):04X}"]
for enc in ["shift_jis", "cp932"]:
try:
row.append(f"{enc}={ch.encode(enc).hex(' ').upper()}")
except UnicodeEncodeError as e:
row.append(f"{enc}=UnicodeEncodeError")
print(" ".join(row))
① U+2460 shift_jis=UnicodeEncodeError cp932=87 40
~ U+FF5E shift_jis=UnicodeEncodeError cp932=81 60
〜 U+301C shift_jis=81 60 cp932=81 60
(python -X utf8 で実行した出力から、この部分を抜き出しています)
- Python の
shift_jisは、①も U+FF5E も書けない。.NET のshift_jis(932)は書ける - U+301C は、Python では
shift_jisでもcp932でも81 60に書けた。.NET の 932 では「?」
つまり、「Shift_JIS」という名前で呼んでいても、中の表は言語やライブラリによって違います。今回わかったのは、この2つの実装でこう違った、というところまでで、どちらが正しいという話ではありません。システムをまたいで Shift_JIS のデータをやりとりするなら、丸数字・波ダッシュ・はしご高のような文字を入れたテストデータで、行って帰ってこられるかを実際に確かめるのが確実です。
本店と支店で、同じ『Shift_JIS』の番号表を貼ってるはずなのに、端っこの番号が違う
それ、番号表のコピーに失敗してませんか
失敗ではなく、もともと版が違う番号表なんです。だから、番号表の名前だけでなく、中身を確かめます。
置き換えるか、例外にするか
文字コードの変換で表にない文字に出会ったとき、.NET の Encoding は、置き換え(読むときは U+FFFD、Shift_JIS に書くときは今回「?」)をするか、例外を出すかを選べます。
- 置き換え:止まらないが、データが変わる。何件置き換わったかを、あとから数えにくい
- 例外:止まるが、どのデータが表にないかがすぐ分かる
.NET の解説では、Encoding.GetString や GetBytes は正しくない並びを見つけると置き換えをし、組み込みの Encoding は例外を出す設定にもできる、と説明されています。
確認ツールで DecoderFallback.ExceptionFallback を使ったのは、「読めた」と「黙って置き換えて読めたことにした」を区別したかったからです。
置き換えは、注文と違ううどんを黙って出す店。例外は、作れませんと謝る店
謝る店のほうが、また来たくなるね
どちらを選ぶかはシステムしだいですが、選んだことを知らずに置き換えに任せるのだけは避けたいところです。
冒頭の新人さんに、もう一度
冒頭では、取引先の CSV が「���ǂ�」になっていました。
今なら、こう説明できます。
�が出たので、Shift_JIS で書かれた CSV を UTF-8 で読んでいたんだと思います。RegisterProvider してから shift_jis を指定して読んだら、商品名が出ました。返すファイルは、相手に BOM あり・なしを聞いてから書きます
文字化けの見た目から書いた側の文字コードを当てたの、すばらしいです
おうどんより先に、番号表を読める人になった
文字化けは、文字が壊れたのではなく、書いた表と読んだ表が違うだけ。
だから、直すときに見るのは文字ではなく、どの表で書いて、どの表で読んだか、です。
まずは、手元のコードの File.ReadAllText を1か所探して、Encoding が書いてあるか見てみてください🍜
参考資料
- Microsoft Learn:Introduction to character encoding in .NET — コードポイントの範囲、string は char(UTF-16)の並びで Length は char の数、サロゲートペア、Rune と StringInfo、Substring でペアを分ける例、UTF-8 は 1〜4 個のコード単位、GetString の U+FFFD 置き換えと例外を出す設定
- Microsoft Learn:CodePagesEncodingProvider クラス — .NET Core で最初から使える文字コードの一覧、それ以外のコードページは RegisterProvider で登録して使う
- Microsoft Learn:File.WriteAllText メソッド — 引数なしの版は BOM なしの UTF-8、BOM を付けたいなら Encoding.UTF8 を渡す
- Microsoft Learn:Encoding.UTF8 プロパティ — 返す UTF8Encoding は BOM を付ける、BOM なしならコンストラクターを使う、例外を出す UTF8Encoding(true, true) の例
- Microsoft Learn:File.ReadAllText メソッド — BOM があれば UTF-8・UTF-16・UTF-32 を自動で見分ける
- RFC 3629:UTF-8, a transformation format of ISO 10646 — 背番号の範囲ごとの 1〜4 バイトの並べ方、US-ASCII は1バイトのまま、UTF-8 の BOM は EF BB BF
.NET 10 と公式ドキュメントで確かめた、2026年10月時点の内容です。










コメント