プログラミング言語の文字コードの話

技術,プログラミング言語,文字コード

色々面倒くさい文字コードの話です。Unicodeの知識が前提になります。技術的なことは正解を先に見つけてしまったので、基本的に例によってポエムです。

発端

Rustのツアーにある文字列の章を見ていて、なんか文字集合と文字符号化形式をごっちゃにしていてわかりにくいな、と思ったのが発端です。

英語の原文はこう。

With so much difficulty in working with Unicode, Rust offers a way to retrieve a sequence of utf-8 bytes as a vector of characters of type char.
A char is always 4 bytes long (allowing for efficient lookup of individual characters).

日本語訳はこう。

Unicodeでの作業が非常に困難なため、Rustではutf-8バイトのシーケンスを char 型の文字のベクトルとして取得する方法が提供されています。
char は常に 4 バイトの長さです。(これによって個々の文字を効率的に検索できるようになっています)

Unicodeの知識がある人なら読み解けるでしょうが、ない人にはちょっとわかりにくい気がします。

背景を織り込んで意訳するとこんな感じでしょうか。

可変長文字列であるUTF-8によるUnicodeの作業が非常に面倒なため、RustではUTF-8バイト列をchar型の文字のVectorとして取得する方法が提供されています。
char型は4バイト固定長です。(これによって個々の文字を効率的に検索できます)

で、Goでも文字列はUTF-8でしたが、他のプログラミング言語の文字コード事情はどうなっているのかな?ということが気になりました。

なお、技術的なことは以下に詳しいので、これ以降自分が書くことを見ないで、こちらを参照する方が役に立ちます。

夢の統合文字コードUnicodeは一つではない

上でもさらっと触れましたが、Unicodeは世の中の文字を一つの文字コードにまとめるという崇高な理想のためにとても面倒な仕様になっています。

そもそもは、2バイト固定長文字列で世界中の文字をまとめてしまえばローカライズなんて面倒なこともしなくてよくなるんじゃね?という夢から始まったUnicode。それが当然のようにうまくいかなかったことがその後の混乱を生み出し、今でも続いています。

世界の文字を2バイトの範囲に押し込めるため、見た目が似たような文字は皆まとめてしまえ、というのが当初の思想でした。例えば、日本ではよく問題になる高橋の「高」の字。大雑把に言っても普通の高と髙(いわゆる「はしごだか」)があります。外国人から見れば「これはフォントの違いで同じ文字でしょ」となるのでまとめようとしますが、当然漢字文化圏(というか日本)からは反発されました。そんなこんなで2バイトで世界の文字を全て表そうという野望は潰え、21ビットで表現することになりました。

文字集合と文字符号化方式

さて、世の中の文字を21ビットで表現することになったのは良いですが、それはあくまで文字集合の話。世の中の文字を集めて、その一つ一つにコードポイントというものを割り振ったものが文字集合となります。昔は符号化文字集合と言っていた気がします。

これが文字コードだよね?と思うとちょっと違います。文字コードではあるのですが、あくまで仮想的なもので、現実の文字コードとして何らかのバイト列にするための方式をまとめたものが文字符号化方式であり、いわゆるUTF(Unicode Transformation Format)というものです。

過去には色々ありましたが、現在主流なのは3つです。

  • UTF-8:1〜4バイト可変長
  • UTF-16:2バイトないし2バイト2組(4バイト)の変則的な固定長
  • UTF-32:4バイト固定長

最後のUTF-32の事例を自分はあまり知りませんが、どこかにあるのでしょうか?主に使われるのはUTF-8とUTF-16かと思います。

Windowsの内部表現はUTF-16だったりするし、ファイルとしてはASCIIコードと親和性の高いUTF-8もよく使われていたり、と一口にUnicodeと言っても物理的な文字コードとしては少なくとも2つの主流があります。

さらにUTF-16/32にはエンディアンがあり、それを示すためのBOMの有無というバリエーションもあって、夢の統一コードUnicodeがちっとも統一されていない、という状況にあります。

さらに、サロゲートペアとか混乱を助長するものもありますが、ここでは割愛します。基本的に昔ながらのJIS第1水準漢字くらいの範囲であれば、Unicodeのコードポイント=UTF-16と思っても概ねバチは当たらない気がしますが、どんどん増える絵文字とか記号とかを視野に入れると、色々破綻します。
そういう意味では一番素直なのがUTF-32ですが、あまり見かけなかったりします。結局、プログラミング言語ではUTF-8が多いイメージですが、Rustツアーにも書いてある通り、可変長文字コードなので扱いが面倒です。

各言語の文字コードの扱い

せっかくなので、いろいろな言語で内部的な文字コードの扱いと、文字列の長さや位置の単位がどうなっているのか確認してみました。

おことわり

全ての言語を網羅できないため、個人的な興味の範囲での調査であることはご了承ください。
また、言語によってはマルチロケール対応の文字列が別にあったりしますが、今回は素の文字列型を取り上げています。
言語によっては環境依存の場合がありますが、確認しているのはMacBook Pro(macOS Monterey 12.3.1)になります。

Rustの場合

まずは発端となったRustの場合。ツアーにある通り、文字列型はUTF-8バイト列として扱われ、カウントは文字数ではなくバイト数になります。

use std::io;

fn main() {
    let message = "ABC123🇯🇵😀";
    println!("{}", message.len()); // -> 18
    println!("{}", &message[6..14]); // -> 🇯🇵
    println!("{}", &message[14..18]); // -> 😀
}

"ABC123🇯🇵😀"という文字列の長さは18バイトになります。英数字は各文字1バイトで6バイト、日本国旗は8バイト、笑い顔の絵文字は4バイトになります。

Goの場合

Goも文字列の文字コードはUTF-8と書いてあります。こちらも文字数ではなくてバイト数になってます。

package main
import "fmt"
func main(){
    message := "ABC123🇯🇵😀"

    fmt.Println(len(message)) // -> 18
    fmt.Println(message[6:14]) // -> 🇯🇵
    fmt.Println(message[14:18]) // -> 😀
}

Javaの場合

Javaの場合はその登場時点からUnicode(UTF-16)を内部コードとして使っており、Unicodeに関連したAPIを色々持っています。が、Java8になって一部のライブラリはUTF-8がデフォルトになったようです。Java界隈から離れていたので知りませんでした。
他の言語と違い、長さ12で位置指定はバイト数です。日本国旗が4バイトですので、おそらく昔ながらのUTF-16かと思います。

import java.util.*;

public class Main {
    public static void main(String[] args) throws Exception {
        String message = "ABC123🇯🇵😀";

        System.out.println(message.length()); // -> 12
        System.out.println(message.substring(6, 10)); // -> 🇯🇵
        System.out.println(message.substring(10, 12)); // -> 😀
    }
}

Pythonの場合

今、もっとも熱い気がするPython。自分はあまり触ったことないです。Pythonの文字列も標準でUTF-8とのことです。
バイト数ではなくて文字数です。

message = "ABC123🇯🇵😀"
print(len(message)) # -> 8
print(message[6:8]) # -> 🇯🇵
print(message[8:9]) # -> 😀

Rubyの場合

Pythonを見たのでRubyも見てみます。Rubyの場合は文字列にエンコーディングを持てるという仕組みだそうです。デフォルトではUTF-8のようです[1]

message = "ABC123🇯🇵"
puts(message.length) # -> 8
puts(message.encoding) # -> UTF-8
puts(message.slice(6,2)) # -> 🇯🇵
puts(message.slice(8,1)) # -> 😀

JavaScriptの場合

これもバージョンによって色々ありそう。名前が似ているからではないでしょうが、Java同様にUnicode(UTF-16)を採用しているとのこと。こちらもバイト数になります。

const message = "ABC123🇯🇵😀";

console.log(message.length); // -> 12
console.log(message.substr(6, 4)); // -> 🇯🇵
console.log(message.substr(10, 2)); // -> 😀

C++の場合

競プロ界の覇者、C++です。マルチバイト文字実装は実装依存だそうです。C++は仕様がどんどんアップデートされますが、バージョンによって文字列周りの扱いも違ってくるようです。詳細は書ききれないので割愛します。
文字列カウントはバイト数単位ですが、非ASCII文字部分の数え方が他のバイト数単位の言語と扱いが違うようです。

#include <iostream>
using namespace std;
int main(void){
    string message = "ABC123🇯🇵😀";
    cout << message.size() << endl; // -> 18
    cout << message.substr(6, 8) << endl; // -> 🇯🇵
    cout << message.substr(14, 4) << endl; // -> 😀
}
補足

UTF-8文字列リテラルというのが使えるはずですが、試した環境ではうまく動きませんでした。

Swiftの場合

Swift5から内部コードがUTF-8になったそうです。それ以前はUTF-16だったとか。文字数でカウントされています。
しかし、Swiftってこれに限らずバージョンが変わると結構いろんなことをコロコロ変えますね。

let message = "ABC123🇯🇵😀"
print(message.count) // -> 8
let start = message.index(message.startIndex, offsetBy: 6)
let end = message.index(message.startIndex, offsetBy: 7)
print(String(message[start..<end])) // -> 🇯🇵

まとめ

今回確認した言語だけで全てを語るのは烏滸がましいですが、一応まとめです。

基本的には最近の言語は内部的にUTF-8、昔ながらの言語はUTF-16という流れがあるように見えます。もっとも、JavaやSwiftのように宗旨変えする例もあります。

長さの単位については文字数ベースとバイト数ベースが(上で見た限りは)半々程度で拮抗している感じです。ただ、使う方からすればバイト数単位にする必要性がよく分かりませんでした。バイトだけ取り出したい時はそれ用のデータ型に変換する方が良いと感じるのですが…。

大雑把に分けるとコンパイラ言語はバイト数、インタプリタ言語は文字数という傾向があるので、処理効率とかを元に決まってくる感じでしょうか。コンパイラ言語では必要に応じてプログラマの責任で処理しろ、ということでしょうか。

宗旨替えされるケースもあるので、特定の内部コードを前提としたコードの書き方はよろしくない、というのが結論になります。

2023-01-16技術Unicode,プログラミング言語,文字コード

Posted by woinary