1. はじめに
コンピュータがどれだけ賢くても、文字 "A" や記号 "⌘" の意味をそのまま理解しているわけじゃないよね。コンピュータが理解するのは 0 と 1 だけで、人間にわかる文字に変換するには「通訳」=エンコーディングが必要なんだ。
エンコーディングの歴史は妥協と進化の歴史でもあるよ。最初は単純だったけど、だんだん複雑になって、やがて比較的ユニバーサルな標準が登場した。年代順に見ていこう。
始まり
まずは源流から。前の講義でテキストエンコーディングの祖先である ASCII を思い出したよね(発音は「アスキー」)。正式名称は American Standard Code for Information Interchange で、名前どおりアメリカ向けに作られたんだ。
ASCII は1960年代に作られて、最初に広く使われた文字コードの標準だよ。128 文字のセットになっている:
- ラテン文字(大文字・小文字): A-Z, a-z
- 数字: 0-9
- 句読点: .,!?"' など
- 制御文字の一部: 改行、タブなど
これら 128 文字はそれぞれ 1 バイトで表現され、実際には 8 ビット中 7 ビットを使ってた(最上位ビットはチェックや未使用だった)。英語にはすごくコンパクトで効率的だよ。
例:
文字 'A' は ASCII ではバイト 0x41 (2進数 01000001) で表される
文字 '!' は ASCII ではバイト 0x21 (2進数 00100001) で表される
制限:
ASCII の最大の弱点は明白で、英語向けに最適化されていること。ロシア語("Привет")、ドイツ語("Grüße")や中国語を書きたい時には役に立たない。テーブルにその文字は存在しないからね。そこで 128 文字を 256 文字に拡張するいろんな「コードページ」が出てきた。例えばロシア語向けには CP1251(Windows Cyrillic)、KOI8-R など。でも問題は、これらのコードページ間で互換性がないこと。同じバイトが別のコードページでは全く別の文字を意味することがあった。まさにバベルの塔状態だよ。
今日の実用面:
純粋な ASCII フォーマットは今ではあまり一般的じゃない(特別な用途か古いシステムぐらい)。でも遺産は生きていて、多くの現代的なエンコーディングは ASCII と互換性があることが多いんだ。
ちょっと実験しよう。まず ASCII に書き込んで読み出してみて、その後にロシア文字を追加して何が起きるか見てみよう。
JetBrains Rider で新しいコンソールプロジェクトを作って、例えば FileEncodingExplorer と名付けてみるよ。
using System;
using System.IO;
using System.Text;
string file = "ascii.txt";
string asciiText = "Hello, world!";
string cyrillicText = "Привет, мир!";
// ASCII に書き込み
using var writer = new StreamWriter(file, false, Encoding.ASCII);
writer.WriteLine(asciiText);
writer.WriteLine(cyrillicText);
// ASCII から読み込み
using var reader = new StreamReader(file, Encoding.ASCII);
string content = reader.ReadToEnd();
Console.WriteLine("ファイルの内容:");
Console.WriteLine(content);
Console.WriteLine("\nキリル文字は '?' に置き換わるよ。ASCII はキリル文字をサポートしてないからね!");
このコードを実行すると、英語部分は正常に読めるけど、ロシア語はクエスチョンマーク(?)や他の「不明」文字に変わるのが分かるよ。これは Encoding.ASCII がキリル文字をバイトに変換する方法を知らないからで、存在しない文字は安全な代替(通常は ?)に置き換えられるからなんだ。あるいは別のエンコーディングでのバイトが ASCII として解釈されて別の文字になることもある。StreamWriter が書き込み時に見つからない文字を強制的に ? に置き換えるのはその典型例だよ。だから正しいエンコーディングを使うのが重要なんだ。
2. UTF-8: インターネットの王様で柔軟性の塊
ここで今日最も重要で、たぶん最も広く使われているエンコーディングのひとつ — UTF-8 に到達するよ。インターネットの大部分、Linux 系、モダンなアプリの多くがこれで動いてるんだ。
どんなもの?
UTF-8 (Unicode Transformation Format - 8-bit) は Unicode の別のエンコーディングで、特に英語テキストに対して UTF-16 の非効率性を解決するために考えられたんだ。UTF-8 は 可変長エンコーディング だけど、賢いやり方をしているよ:
- 普通の ASCII 文字(コード 0〜127)は 1 バイト でエンコードされる。しかもそのバイトは完全に ASCII と同じだから、UTF-8 は ASCII と後方互換性があるんだ。
- その他の文字は 2〜4 バイトで表現される:
- キリル文字 — 通常 2 バイト。
- ヨーロッパのダイアクリティカル付き文字、アラビア文字、ヘブライ文字、ギリシャ文字など — 2 バイト。
- 中国語/日本語/韓国語の漢字 — 多くは 3 バイト。
- 稀な文字や一部の絵文字 — 4 バイト。
UTF-8 のバイト表現の例:
- 文字 'A' (ASCII): 01000001 (1 バイト)
- 文字 'я' (ロシア語): 11010001 10111111 (2 バイト)
- 文字 '€' (ユーロ記号): 11100010 10000010 10101100 (3 バイト)
- 文字 '😂' (絵文字): 11110000 10011111 10011000 10000010 (4 バイト)
なぜ UTF-8 が「王様」なの?
- 効率性: ASCII 文字が多いテキスト(英語、プログラムのソース、設定ファイルなど)では非常にコンパクト。
- ASCII との互換性: ASCII のみを含む UTF-8 ファイルは ASCII として読めるし、そのまま動くことが多い。
- BOM が基本的にない: UTF-16 と違って、UTF-8 は通常 BOM を使わない。もし付くことがあっても(例: EF BB BF)、それは任意の「機能」で、場合によっては問題を起こすことがある(例えば一部のフォーマット解析や Linux のスクリプトで)
欠点:
- 可変長なため、例えば「N 番目の文字にジャンプする」みたいな操作は全体を走査しないと難しい場合がある。でも C# では問題にならない: string はファイルのエンコーディングに関係なくユニコード文字を扱ってくれるからね。
実用例:
- ウェブページ (HTML, CSS, JavaScript)、
- API (JSON, XML)、
- 設定ファイル、
- 多くのプログラミング言語のソースコード、
- Linux/Unix 系 OS。
じゃあ UTF-8 でファイルを書いて読み込む例を書いてみよう。
string file = "utf8.txt";
string text = "Hello, 世界! 😀 €";
// UTF-8 に書き込み(デフォルトは BOM なし)
File.WriteAllText(file, text, Encoding.UTF8);
// UTF-8 から読み込み
string readText = File.ReadAllText(file, Encoding.UTF8);
Console.WriteLine(readText); // 正しく読めるよ!
このコードを実行してファイルサイズを比べると、混在したテキストの場合 utf8.txt は通常 UTF-16 より小さいし、完全に英語だけなら ASCII と同等になることが多いよ。
3. UTF-16: ほぼすべてをカバーする Unicode
コードページのバラバラ問題は、アプリがグローバル化すると本当に辛い問題になった。そこで登場したのが Unicode。Unicode はそれ自体が「エンコーディング」ではなく、あらゆる既知の文字に一意の数値コード(code point)を割り当てた巨大なテーブルだよ。
どんなもの?
UTF-16 (Unicode Transformation Format - 16-bit) は、当初はすべての Unicode 文字を 2 バイト(16 ビット)で表せるように考えられていたエンコーディングだよ。
- ほとんどの文字(BMP、65535 まで)は 2 バイトで表される。
- BMP 外の文字はサロゲートペア(surrogate pair)で表現されて 4 バイトになる。つまり UTF-16 も可変長だけど、普通は 1 文字 ≒ 2 バイトと考えられることが多いよ。
バイト順(Endianness)と BOM:
- Big-Endian (BE): 上位バイトが先。
- Little-Endian (LE): 下位バイトが先。
- 読み手がバイト順を知るためにファイルの先頭に BOM(Byte Order Mark)を置くことが多い:
- UTF-16 LE の場合: FF FE
- UTF-16 BE の場合: FE FF
利点:
- 世界中のほとんどの文字をサポートする。
- BMP 内の文字の扱いは簡単(固定長で 2 バイト)。
欠点:
- 英語のテキストには非効率: ASCII の文字でも 2 バイト使う。
- BOM があると、読み手がそれを期待していない場合に問題になることがある。
実用面:
UTF-16 は Windows の内部や、例えば Java の文字列内部表現で広く使われているよ。Windows のメモ帳がキリル文字を含むテキストを保存するときは UTF-16 LE + BOM で保存されることがよくあるんだ。
string file = "utf16.txt";
string text = "Hello, 世界! 👋";
// UTF-16 に書き込み(デフォルトは Little-Endian、BOM 付き)
File.WriteAllText(file, text, Encoding.Unicode);
// UTF-16 から読み込み
string readText = File.ReadAllText(file, Encoding.Unicode);
Console.WriteLine(readText); // 正しく読めるよ!
Console.WriteLine($"ファイルサイズ: {new FileInfo(file).Length} バイト");
実行すると全ての文字が正しく表示されるはず。英語だけのファイルだと UTF-16 はおおよそ ASCII / UTF-8 の 2 倍のサイズになることが多いよ(ASCII 範囲の文字を扱う場合)。
4. エンコーディング比較まとめ表
知識を整理するために、主要な特徴を表にまとめるよ。
| エンコーディング | 文字あたりの最小バイト数 | 文字あたりの最大バイト数 | ASCII との互換性(直接) | .NET でデフォルトで BOM を使うか | 利用例 |
|---|---|---|---|---|---|
| ASCII | 1 | 1 | 完全 | いいえ | 古いシステム、非常に単純なテキストデータ、内部プロトコル |
| UTF-16 | 2 | 4 | いいえ | はい (Encoding.Unicode) | Windows の文字列内部表現、Java の内部表現、Windows のテキストファイル |
| UTF-8 | 1 | 4 | 完全 | いいえ (Encoding.UTF8 in .NET 5+); はい (Encoding.UTF8 in .NET Framework) | ウェブ(HTML, JSON)、設定ファイル、ソースコード、Linux/Unix |
Encoding.UTF8 に関するちょっとした注意:
歴史的に .NET Framework の Encoding.UTF8 はデフォルトで BOM を付けていたよ。モダンな .NET (Core/5+) ではデフォルトの挙動が変わって、通常は BOM を付けないんだ。もし BOM が必要なら new UTF8Encoding(true) を使ってね。
5. C# でエンコーディングを指定する方法
例で見たように、StreamReader や StreamWriter にどの「辞書」を使うかを教えるには、System.Text.Encoding のオブジェクトを渡せばいいんだよ。
System.Text.Encoding はいくつかの定義済みオプションを提供しているよ:
- Encoding.ASCII: ASCII 向け。
- Encoding.Unicode: UTF-16 LE(BOM 付き)。
- Encoding.UTF8: UTF-8(モダンな .NET ではデフォルトで BOM なし)。
他のエンコーディングは Encoding.GetEncoding で利用できるよ(例: "windows-1251", "koi8-r")。でも今は Unicode 系が主なフォーカスだね。
// UTF-8 に書き込み
using var writer = new StreamWriter("my_file.txt", false, Encoding.UTF8);
writer.WriteLine("何かのテキスト。");
// UTF-16 から読み込み
using var reader = new StreamReader("another_file.txt", Encoding.Unicode);
string content = reader.ReadToEnd();
Console.WriteLine(content);
これがすべてのコツだよ!StreamReader と StreamWriter が選ばれたエンコーディングのルールに従って、文字とバイトの変換を全部やってくれるんだ。
6. エンコーディングの問題: 「文字化け」
先ほど ASCII にロシア語を書いたときに「文字化け」が出たのを見たよね。でも一番厄介なのは、あるエンコーディングでファイルを書いて、別のエンコーディングで読んでしまう場合だよ。ここから本当の地獄(面白いけどね)が始まる。
例えばロシア語で書かれたテキストを UTF-8 で保存してあるのに、クライアント側がそれを CP1251 として読もうとしたら、バイト列の解釈が間違ってしまって、"Привет, мир!" の代わりに「文字化け」(英: mojibake) が出るんだ。
原因は一つ: 書き込み時と読み込み時のエンコーディングが一致していない こと。書く側と読む側で同じエンコーディングを使うのが基本。意図的に再エンコードする場合を除いてね。
string file = "mismatch.txt";
string russianText = "Привет, мир!";
// UTF-8 で書き込み(正しい!)
File.WriteAllText(file, russianText, Encoding.UTF8);
// 間違った読み込み: UTF-8 ファイルを ASCII として読む
string readAsAscii = File.ReadAllText(file, Encoding.ASCII);
Console.WriteLine($"オリジナル: {russianText}");
Console.WriteLine($"ASCII として読み込んだ結果: {readAsAscii}"); // ここで文字化けが起きるよ!
プログラムを実行すると、キリル文字がクエスチョンマークや意味のない記号に変わるのが見えるよ。次のレッスンでは、エンコーディングをより柔軟に扱う方法、こうした問題を避けるコツ、そして読み込もうとしているファイルのエンコーディングをどう判別するかを話す予定だよ。
GO TO FULL VERSION