mtkw.dev

文字コードについて学んだことの整理

これは何

文字集合や文字コードについて学んだことの整理をしました。

符号化文字集合とは

文字集合の各文字に一意の「コードポイント」or 「文字コード」をを割り当てたものです。 例:Unicode・ASCII・JIS

文字集合によって符号化の意味が変わる点に注意です。 Unicodeは文字とコードポイントを対応付けることを符号化と呼びます。コードポイントから文字コードへの変換はUTF-8/16/32といった符号化方式で定義されています。 一方、JIS X 2021では文字と文字コードを直接紐付けることを符号化と呼んでいます。

例えば、Unicodeであれば「A」という文字に「U+0041」という一意のコードポイントを振り、UTF-8で「41(16進数)」という文字コードを割り振ります。 一方、JIS X 2021では「A」という文字に直接「0x41」という文字コードを割り振ります。

ざっくりJIS Xの歴史

1976年:JIS X 0201 ... ASCII互換 + 半角カタカナ。1バイトでエンコード
1978年:JIS X 0208 ... ひらがな・カタカナと漢字を追加。文字集合の定義のみで、文字からバイト列への符号化方式はShift_JISやEUC-JPで定義された。
1990年:JIS X 0212 ... 漢字を追加。ほとんど普及せず。
2000年:JIS X 0213 ... 0212を整理し統合した規格。漢字を追加。

2000年代以降はUnicodeが世界標準になりJISコードを使うことはほとんどありません。
JIS X 0208からはUnicodeに似た形式になった点が特徴的です。 Unicode:文字<->コードポイント<->文字コード
JIS系:文字<->区点番号<->文字コード

ASCII

最も基本の文字コードは1960年代にアメリカ規格として開発されたASCIIです。7ビットの1バイトコードです。
ASCII:American Standard Code for Information Interchange

ASCIIの初期はアメリカでの利用を想定されていたため、通貨記号もドルしかありませんでした。 各国でも使えるように各国用のASCIIが生まれましたが、それに伴い複数の言語を同時に扱うことができない問題が発生しました。

複数の言語を同時に扱うためにASCIIを拡張したISO/IEC 2022が生まれました。 詳細はあまり今の時代に覚えても仕方がないので割愛します。

Unicode および ISO/IEC 10646(UCS)

Unicodeは世界中の文字を収めることを目標にした符号化文字集合です。 Unicodeにより符号化された文字は「U+0000」から「U+10FFFF」という範囲で符号化されます。

Plane 0 U+0000 から U+FFFF
Plane 1 U+10000 から U+1FFFF
Plane 2 U+20000 から U+2FFFF
...
Plane 16 U+100000 から U+10FFFF

といった17個の面に分けます。「BMP」とはBasic Multilingual Planeの略で第0面の文字集合を意味します。 BMPには日常的な文字が入っています。

UCS-2はコードポイントを2バイト固定長で表す方式です。2バイト固定なのでBMPしか表せません。 UCS-4はコードポイントを4バイト固定長で表す方式です。UCS-2では全ての文字を表しきれないとなり生まれました。Plane16までの全てを表すことができます。

UTF概説

UTFはUnicodeの文字エンコーディング方式です。
符号化方式はUTF-8, UTF-16, UTF-32という様々な名前がついています。これは何ビット単位で符号化するかを意味しています。

UTF-16

UTFの直系はUTF-16になります。16ビット単位で文字を表現します。2バイトで表現できるUnicodeの範囲はBMPのみです。そのため、BMP以外の文字を表すためにサロゲートペアという仕組みが生まれました。サロゲートペアとは32ビットの前半16ビットと後半16ビットの組み合わせでUnicodeのコードポイントを紐づける方式です。例えば、「𩸽(ほっけ)U+29E3D」はBMP外の文字のため、上位サロゲート: D867と下位サロゲート: DE3Dの組み合わせで表現します。

UTF-32

UTF-32は32ビットの固定幅でBMPもそれ以外も全符号位置を表現できます。

UTF-8

UTF-8は8ビット単位で文字をエンコードします。UTF-8はASCIIとの互換性のために生まれました。Unicodeの先頭128文字はASCIIの文字コード表のコピーになっています。UTF-16やUTF-32で表すと先頭に「0」が付くためバイト単位でASCII互換ではありません。
1バイト部分はASCII互換性になっているメリットは、昔のASCIIベースのソフトウェアや通信規格と互換性があるためです。また1バイトから4バイトの可変長のため容量が他のエンコーディング方式より効率が良いです。

エンディアン概説

UTF-16とUTF-32にはリトルエンディアンとビッグエンディアンというバイトの並び順に関する問題があります。
UTF-16は2バイト単位でデータを扱うため、その2バイト内の並び順をどうするかはを考える必要があるわけです。
例えば、3042という16bitのデータ存在する場合を考えます。

  • BE(ビックエンディアン) ... 30 42
  • LE(リトルエンディアン) ... 42 30

になります。

これは1バイト単位でデータを扱うUTF-8には存在しない問題です。
UTF-8であれば、3042は3042でしかないのです。

BOM(Byte Order Mark)概説

UTF-16で記載されたファイルを開いたときに、そのファイルがBEかLEかの判別ができません。
そのためBOM(Byte Order Mark)というバイト順を意味する印をファイルの先頭につけます。

ファイルの先頭に「FE FF」を書けば、UTF-16BEを意味し、「FF FE」を書けば、UTF-16LEを意味します。
ファイルの先頭に「00 00 FE FF」を書けば、UTF-32BEを意味し、「00 00 FF FE」を書けば、UTF-32LEを意味します。

UTF-8にエンディアンの問題は存在しませんが、そのファイルがUTF-8であることを意味する「EF BB BF」が存在します。UTF-8はBOMは必須ではないことに注意です。

BOMはファイルEncodingを表す印と理解すると、理解しやすいです。

FEFFとは

Unicodeでは「U+FEFF」はZERO WIDTH NO-BREAK SPACEという文字でもありますが、現在はBOMとしての役割が主流です。 U+FEFFを自動改行がされないように文字として利用することは現在(Unicode3.2以降では)非推奨です。
代わりにU+2060(Word Joiner)の使用が推奨されています。

余談 -自動改行防止文字 -

U+00A0
NO-BREAK SPACE
半角1文字分の幅がある。単語間にスペースを空けつつ改行を防ぐ。
例:100&nbsp;km

U+2060
WORD JOINER
幅はゼロ。スペースを空けずに文字同士が改行を防ぐ。
例:株&#x2060;式&#x2060;会&#x2060;社

結合文字とは

Unicodeの特徴として複数の部品によって1文字を表すことを可能にしたことがあります。合成に用いるための文字を結合文字といいます。

例えば「e + ◌́ = é」です。「◌́」は結合文字であり前の文字を修飾します。結合は単独で存在することはできません。 結合文字がなぜ必要だったのかというと、個別に登録すると膨大になるためです。

しかし、「é」はUnicode上で個別登録もされています。
é:U+00E9
e + ◌́:U+0065 U+0301

これはなぜかというと、古い符号化文字集合(ISO 8859-1、Shift_JIS、EUC-JPなど)との互換性を維持するためです。昔のファイルをUnicodeに変換した場合、文字数が変わったりプログラムが壊れたりするため、合成済み文字も個別で登録しました。

Javaにおける文字列の扱い

Java内部で文字はUnicodeで扱います。

Javaでは文字を扱うクラスが3つあり、UTF-16で処理を行います。

  • char ... UTF-16の1つのコードユニット.
  • Charactor ... charのラッパークラス.便利メソッドが用意されている.
  • String ... 文字列クラス. charの配列.

そのため以下は可能です。

char c1 = 'A';
char c2 = 'あ';

一方で以下は不可能です。

char c3 = '😀';

なぜかというとUTF-16における絵文字はサロゲートペアで表現されるため、4バイト(32bit)が必要になり、char型(16bitの箱)に絵文字を入れることはできません。これはKotlin/JVMでも同じです。

サロゲートペア問題

サロゲートペアで表現された文字が文字列に存在する場合、以下のような問題が発生します。

// ホッケというBMP外の文字を含む文字列
var text = "𩸽あいうえお";
// 出力は7であり、6ではない ...①
System.out.println(text.length());
// 出力は2であり、1ではない ...②
System.out.println(text.indexOf("あ")); 
var firstChar = text.charAt(0);
// 出力は?であり、𩸽を出力できない ...③
System.out.println(firstChar); 
// U+D867(𩸽の上位サロゲート)が出力される...④
System.out.printf("U+%04X%n", (int) firstChar); 

①について

textは6文字のように見えますが、①において7が出力されます。
これは"𩸽"という漢字がBMP外に定義されサロゲートペアで表現される文字のためです。
String.length()はchar配列の長さを出力するコードでした。"𩸽"には32bit必要なのでcharは2つ必要であり𩸽だけで長さが2になります。

②について

"あ"の開始位置をindexOfで取得しています。"1"が返却されるように見えますが、"2"が返却されます。
これは①の話と同じですが、Stringはchar配列であり、"𩸽"には2つ分のcharを必要とします。そのため"あ"の開始位置は"2"になります。

③④について

charAt(0)で先頭のcharを取得しています。③では"𩸽"が出力されそうに見えますが"?"が出力されます。
charAt(0)で取得したデータは"𩸽"の上位サロゲートのため文字としては表現できないためです。
④で出力すると"𩸽"の上位サロゲートであることがわかります。

サロゲートペア対応

サロゲートペアの問題を解決するためにJDK1.5からString#codePointAt()String#codePointCount()が用意されました。

// ホッケというBMP外の文字を含む文字列
var text = "𩸽あいうえお";
// 出力は6であり見た目通りの長さで取得できる
System.out.println(text.codePointCount(0, text.length())); 
// codePointの戻り値の型がintになっている
var codePoint = text.codePointAt(0); 
// U+29E3D(𩸽のコードポイント)、サロゲートペアのセットで取得できている
System.out.printf("U+%X%n", codePoint); 

charとは何か

サロゲートの問題からわかるように、String.charAtで文字を取得できないとなると、charを文字と呼ぶのは不適切であることがわかります。
JDKのドキュメントでもサロゲート対応前はcharを文字と呼んでいましたが、導入後はコードユニットという位置付けがなされています。文字データの構成単位という程度の意味しか持たないと解釈できます。

  • char = UTF-16のコードユニット
  • int = Unicodeのコードポイント

これがより正確な理解と言えます。

コードユニットとはUTF-16やUTF-8などの符号化方式で使う最小単位を意味します。
コードポイントとはUncodeが割り当てた番号を意味します。

参考:Class Character

入出力における文字コード変換

Javaは内部でUnicodeを用いていることがわかりましたが、ファイルやネットワークへの入出力においてはどうするのでしょうか?

正解はReaderやWriterでエンコードを指定しましょう。

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
 
public class Main {
  public static void main(String[] args) {
    try(BufferedReader reader = Files.newBufferedReader(
            Path.of("test.txt"),
            StandardCharsets.UTF_8 // UTF-8を指定して読み込む
    )) {
      for(String line = reader.readLine(); line != null; line = reader.readLine()) {
        System.out.println(line);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
 

この指定によりどの文字コードで読み書きするかを決めましょう。

Java17以前はOSのデフォルトの文字コードが利用されました。
WindowsであればMS932, LinuxであればUTF-8というように、実行環境でデフォルトのエンコードが異なっていました。

Java18ではデフォルトの文字コードは実行環境に依らずUTF-8に統一されました。
Files.newBufferedReaderでCharsetの指定がなければUTF-8でエンコードされます。

実務上はデフォルト値は利用せずに必ずCharsetを指定して読み書きの処理をしましょう。 思わぬバグを引き起こす可能性があります。

Javaで利用できる文字コード

StandardCharsetsで必ず利用できる文字コードが定義されています。

StandardCharsets.UTF_8
StandardCharsets.UTF_16
StandardCharsets.UTF_16BE
StandardCharsets.UTF_16LE
StandardCharsets.US_ASCII
StandardCharsets.ISO_8859_1

これ以外にもCharsetは存在しますが、JDKに依存します。

Charset.forName("EUC-JP");

余談 -Rubyでの文字の扱い-

RubyにはStringしかありません。

s = "ABC"
puts s[0] // 出力:A

sの型はString型であり、s[0]の型もString型です。

Ruby内部での文字の扱いはUTF-8です。そのためサロゲートペアの問題も発生しません。

s = "𩸽”
puts s.length // 出力:1(javaでは2でした)

まとめ

符号化文字集合について理解をまとめました。
JVMでは文字はUTF-16で管理されており、サロゲートペアの問題が発生することが学びになりました。