コンテンツへスキップ
Topos Uranos

要約

std::string が保持するのは文字ではなくバイトであり、今日のテキストでは一つの文字が一バイトから四バイトを占めることがある。このレッスンは、日常の言葉が混同している三つのものを区別する。Unicode がコードポイントと呼ばれる番号で識別する抽象文字、UTF-8 がそれを符号化するバイト、そして画面が描く字形である。UTF-8 がコードポイントのビットをどのようにバイトに振り分けるか、なぜ「神戸」が六バイトを占めるのか、文字列の文字数を復号せずに数えるにはどうするかを説明する。続いて、どの操作がテキストを壊すか(バイトごとの反転、バイトごとの大文字化、文字の途中での切断)、どの操作が何の注意もなしに働くか(保存、コピー、連結、検索、出力)、そして C++23 の標準ライブラリが何をしないかを示す。ASCII 以外の大文字と小文字、言語に応じた辞書順、正規化である。

学習目標

  1. UTF-8 がコードポイントを一バイトから四バイトにどう符号化するかを説明し、「ñ」のバイトを手で復号する。
  2. ASCII 以外の文字を含む文字列のバイト単位のサイズを予測する。たとえば "神戸"(6)や "プログラミング"(21)。
  3. 後続バイトでないバイトを条件 (c & 0xC0) != 0x80 で数えることによって、UTF-8 文字列の文字数を数える。
  4. UTF-8 テキストを壊す操作(バイトごとの反転、バイトごとの std::toupper、文字の途中での切断)を見分け、それぞれの場合にどうするかを決める。

文字、コードポイント、バイト

三つの層

レッスン 文字・真偽値・型変換・定数 は、char を ASCII 表に従って文字として書かれる一バイトの整数として紹介し、'ぱ' がそれに収まらないことを注意した。レッスン コンパイル・実行・設定 は、"新年" が六バイトを占めることを示し、それを出力できるようにコンソールを準備した。このレッスンはその理由を説明する。ASCII は 0 から 127 までの 128 個の文字に番号を割り当てており、それらは七ビットに収まる。英語のアルファベット、数字、句読点、いくつかの制御コードである。しかし、実際のテキストには仮名や漢字、アクセント記号やウムラウトの付いた文字、ギリシャ文字、数学記号、絵文字が含まれ、その数は数十万にのぼる。したがって、第一にずっと大きな表が必要であり、第二にその番号をバイトで書く方法が必要である。

三つの層を区別しておくとよい。第一の層は抽象文字であり、保存や描画の仕方とは無関係な「小文字のエニェ」という文字そのものである。Unicode は各抽象文字に番号、すなわちコードポイントを割り当てる規格であり、コードポイントは U+ のあとに少なくとも四桁の十六進数字を続けて書く。「ñ」は U+00F1(241)、「A」は U+0041(65、ASCII と同じ番号)、「€」は U+20AC(8364)である。コードポイントは 0 から U+10FFFF まであり、百万をわずかに超える。第二の層はエンコーディング、すなわち各コードポイントをバイトの列に変換する規則である。UTF-8 は、ウェブ、現在のシステム、そしてこのコースが使うエンコーディングである。第三の層は表示、すなわちフォントが画面に描く字形であり、文字が変わらなくても書体によって変わりうる。

もしそうであれば、テキストのデータは、そのエンコーディングがわかるまではテキストではない。バイト c3 b1 は UTF-8 では「ñ」だが、古いコンソールの表で読めば、それとは無関係な二つの記号になる。これはレッスン 1.4 が診断の仕方を教えた不具合である。同じ問題を文書組版の側から見たものが、コース「学術文書作成のための LaTeX」のレッスン「言語と書体」に出てくる。

バイトを見る

文字列のバイトを見るには、それをたどればよい。std::string と std::string_view は char の並びであり、範囲 for ループがそのバイトを一つずつ渡す。それを十六進で書くには書式指定 {:02x}(十六進二桁、左をゼロで埋める)を使う。レッスン 2.4 の {:d} と同じく、2023 年に採択され GCC 15 が適用している同じ規格の修正によって、{:02x} は char を符号なしのバイトとして書く。この規則は重要である。x86-64 上の GCC では char は符号付きだからである。バイト c3 を int に変換すると数 −61-61 になり、{:02x} はそれを -3d と書いてしまうだろう。

C++ のプログラム
#include <print>#include <string_view>​// テキストのバイト。// 事前条件:なし。// 事後条件:text、そのバイト単位のサイズ、各バイトの十六進表記を一行に書く。void printBytes(std::string_view text){    std::print("{:<4}{}:", text, text.size());    for (const char c : text) {        std::print(" {:02x}", c);    }    std::println("");}​int main(){    printBytes("A");    printBytes("ñ");    printBytes("€");    printBytes("😀");    printBytes("神戸");}
出力
A   1: 41ñ   2: c3 b1€   3: e2 82 ac😀  4: f0 9f 98 80神戸6: e7 a5 9e e6 88 b8

各行はテキスト、そのサイズ、そのバイトを示す。長さごとに一文字ずつ:「A」は一バイト、「ñ」は二バイト、「€」は三バイト、絵文字は四バイトを占める。単語「神戸」は六バイト、e7 a5 9e と e6 88 b8 を占め、その size() は 6 であって 2 ではない。「A」がバイト 41、つまり ASCII と同じであることに注意しよう。UTF-8 は ASCII の 128 文字を ASCII とまったく同じに書く。

例エニェから二つのバイトへ

図は「ñ」の符号化を上から下へたどる。コードポイント、そのビット、ビットの振り分け、そしてできあがる二つのバイトであり、プレフィックスのビットが強調されている。

証明

  1. 「ñ」はコードポイント U+00F1、すなわち 15⋅16+1=24115 \cdot 16 + 1 = 241 である。

  2. 二進法では、241=128+64+32+16+1241 = 128 + 64 + 32 + 16 + 1 は 11110001 と書く。127 より大きく 2048 より小さいので、UTF-8 はこれを十一ビットを運ぶ二バイトで書く。十一桁になるまで左をゼロで埋めて 00011110001 とする。

  3. 十一ビットを二つの組に分ける。最初の五ビット 00011 と最後の六ビット 110001 である。

  4. 第一バイトは、二バイトの文字を予告するプレフィックス 110 に第一の組の五ビットを続けたもので、11000011 となる。第二バイトは、後続バイトであることを示すプレフィックス 10 に残りの六ビットを続けたもので、10110001 となる。

  5. 十六進では、1100 0011 は c3、1011 0001 は b1 であり、前のプログラムが「ñ」について書いた二つのバイトである。四ビットずつ行う二進と十六進のあいだの基数の変換は、コース「代数と幾何 I」のレッスン「位取り記数法」で扱ったものである。