Dev Study
← どの言語にもある仕組み — 値・型・数・文字・エラー・メモリ・並行
レッスン 7 / 12·約 8 分·第25期 どの言語にもある仕組み

文字列は文字の列ではない — 切ると壊れる単位

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

同じ書き方でも、何が起きるかはその言語が約束した分しか決まっていません。今回は文字列を「切る」操作で、その約束の違いを見ます。

まず結論

文字列の添字や長さが数えている「要素」は言語ごとに違います。JavaScript は16ビットの値、Python はコードポイント、Go はバイト、Rust は UTF-8 のバイトです。どれも、人が1文字と見るものとは一致しません。

要素の途中で切ったとき、Rust は止まり、Go は壊れたバイト列を黙って持ち続け、JavaScript は UTF-8 に変換する時点で(TextEncoder などが)U+FFFD に置き換えます。違いは、それぞれの仕様が文字列に何を約束したかから来ます。

? 読む前に予測

JavaScript で "a😀b".slice(0, 2) の結果を TextEncoder で UTF-8 に変換すると、どのバイト列になるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

「要素」は言語ごとに違う

"a😀b" を人は3文字と読みます。しかし文字列の添字や長さが数える「要素」は、言語の仕様がそれぞれ決めています。

  • JavaScript: ECMA-262 §6.1.4 は The length of a String is the number of elements (i.e., 16-bit values) within it. と定めます。要素は16ビットの値で、UTF-16 として読むのは Operations that do interpret String values だけです。
  • Python: Strings are immutable sequences of Unicode code points.
  • Go: A string value is a (possibly empty) sequence of bytes.
  • Rust: 要素はバイトですが、Rust libraries may assume that string slices are always valid UTF-8. という約束が付きます。

人の「1文字」は、UAX #29(Unicode 17.0.0)が user-perceived characters と呼び、実装では書記素クラスタで近似するとしています。どの言語の要素も、これとは一致しません。

"a😀b" を4つの単位で見る
見た目a / 😀 / b の3つ
Python の添字の単位
コードポイントU+0061 / U+1F600 / U+0062 の3つ
JavaScript の length の単位
16ビットの値0061 / D83D DE00 / 0062 の4つ
Go(バイト)と Rust(UTF-8 のバイト)の添字の単位
UTF-8 のバイト61 / f0 9f 98 80 / 62 の6つ
  • ・😀 は下の2段では1つの値に収まらない。その途中が「切ると壊れる」位置になる
  • ・何文字と数えるかの食い違い(入力の長さ制限など)は第21期で扱う。ここでは切ったときに何が起きるかを見る

要素の途中で切ると、😀 の断片だけが残ります。その断片を許すか、止めるか、置き換えるかが言語ごとに分かれます。

具体実物

同じ「先頭で切る」を4つの言語で

Docker のコンテナ(すべて aarch64)で、Python 3.14.7・Node v24.21.0・Go 1.27.1・rustc 1.98.1 を使い、"a😀b" を途中で切りました。2回実行して出力は同一でした。

s := "a😀b"
fmt.Println(len(s), utf8.RuneCountInString(s), []rune(s)[1] == '😀')
cut := s[:3]
fmt.Printf("%q valid=%v\n", cut, utf8.ValidString(cut))
for i, r := range cut { fmt.Printf("%d:%U ", i, r) }
let s = String::from("a😀b");
println!("{} {}", s.len(), s.chars().count());
let n = s.len() - 3; // 実行時の値
println!("{}", &s[..n]);

JavaScript は s.slice(0, 2)、Python は s[:2] と、UTF-8 にしたバイト列の [:3] を取っています。

4言語の出力(各言語の該当行の抜粋。Rust はスレッド番号を省略)コマンド出力
### Python 3.14.7
3 6 4 a😀
2 🇯 '\U0001f1ef'
b'a\xf0\x9f' a�
decode: 'utf-8' codec can't decode bytes in position 1-2: unexpected end of data
### Node v24.21.0
4 3 a� "a\ud83d"
false <Buffer 61 ef bf bd> Uint8Array(4) [ 97, 239, 191, 189 ]
### Go 1.27.1
6 3 true
"a\xf0\x9f" valid=false
0:U+0061 1:U+FFFD 2:U+FFFD
### rustc 1.98.1
6 3
 
thread 'main' panicked at /w/main.rs:5:22:
end byte index 3 is not a char boundary; it is inside '😀' (bytes 1..5 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
  • JavaScript: slice(0, 2) は "a\ud83d"、😀 の前半だけです。isWellFormed() は false で、UTF-8 にすると 61 ef bf bd。端末への表示も a� でした。
  • Go: s[:3] は "a\xf0\x9f"。valid=false のまま保持し、何も言わずに進みます。
  • Rust: &s[..3] で panic し、終了コード 101。
  • Python: s[:2] は a😀 で壊れません。壊れたのはバイト列を3バイトで切って復号したときで、既定では例外、errors="replace" なら a� になりました。
✓ ここまでの確認

Go 1.27.1 で s := "a😀b" の s[:3] を取ったとき、実演では何が起きましたか。

抽象原理

壊れ方を決めているのは、型の約束

4つの結果は、先ほどの約束からそのまま出てきます。

Rust の str は常に正しい UTF-8 だと約束しているので、それを破る切り方を拒むしかありません。&s[..n] の §Panics はこう書きます。

Panics if end does not point to the starting byte offset of a character (as defined by is_char_boundary), or if end > len.

ただし同じ Invariant 節は Constructing a non-UTF-8 string slice is not immediate undefined behavior と続け、a non-UTF-8 string slice can lead to undefined behavior down the road とも書きます。不正な str を作った瞬間ではなく、それを正しい UTF-8 と仮定する関数に渡した先で未定義動作になりうる、という約束です。

Go は逆に何も約束していません。Go blog は a string holds arbitrary bytes. It is not required to hold Unicode text, UTF-8 text, or any other predefined format. と明言します。そのため s[:3] は正当な操作です。不正な並びをどう読むかも仕様が決めています。

If the iteration encounters an invalid UTF-8 sequence, the second value will be 0xFFFD, the Unicode replacement character, and the next iteration will advance a single byte in the string.

U+FFFD が2つ出たのは、2バイトを1バイトずつ読んだからで、実装の選択ではありません。

JavaScript の文字列も、単独のサロゲートを持てます。Unicode 17.0.0 の D76 は Unicode スカラー値を Any Unicode code point except high-surrogate and low-surrogate code points と定義し、UTF-8 などの符号化形式が扱うのはこの範囲です。単独サロゲートは入りません。WHATWG Infra は、文字列をスカラー値文字列に変換する手順を replace any surrogates with U+FFFD と定めます。TextEncoder の encode は引数を USVString として受け取る(Uint8Array encode(optional USVString input = "");)ので、UTF-8 にする前にこの置換を通ります。ef bf bd はその結果です。

具体実物

Python は壊れないのか

Python の添字はコードポイント単位なので、😀 を半分にはできません。それでも2つの穴があります。

1つ目は、コードポイントが見た目の1文字と一致しないことです。🇯🇵 は U+1F1EF と U+1F1F5 の2つで、len は 2、"🇯🇵"[:1] は 🇯 を返しました。正しいコードポイントですが、国旗ではありません。

2つ目は、単独サロゲートの扱いです。Python の str も "\ud83d" を持てますが、UTF-8 にする時点で JavaScript と違う道を取ります。

Python 3.14.7(aarch64)で "\ud83d" を UTF-8 にするコマンド出力
encode lone surrogate: 'utf-8' codec can't encode character '\ud83d' in position 0: surrogates not allowed
surrogatepass: b'\xed\xa0\xbd'

JavaScript は置き換えて進み、Python は例外で止めます。明示的に surrogatepass を指定すれば書き出せますが、それは正しい UTF-8 ではない3バイトです。

✓ ここまでの確認

単独のサロゲート \ud83d を UTF-8 にしようとしたとき、Python 3.14.7 と JavaScript(Node v24.21.0)の振る舞いはどう違いましたか。

⇄ 2つを見比べる
具体例 A

Rust: &s[..3]

str は常に正しい UTF-8 だと約束している。その約束を守るため、文字の境界でない位置での切り出しは panic になり、プログラムは終了コード 101 で止まった。

rustc 1.98.1 / aarch64(抜粋)コマンド出力
6 3
end byte index 3 is not a char boundary; it is inside '😀' (bytes 1..5 of string)
exit=101
具体例 B

Go: s[:3]

string は任意のバイトを持てると明言している。切り出しは正当な操作として通り、不正な UTF-8 を保持したまま進む。気づけるのは、後で検査するか range で U+FFFD を見たときだけ。

go1.27.1 linux/arm64コマンド出力
6 3 true
"a\xf0\x9f" valid=false
0:U+0061 1:U+FFFD 2:U+FFFD

同じバイト単位の添字で、片方は止まり、片方は黙って進みました。バグに早く気づけるのはどちらで、その代わりに何を払っていますか。

止まる言語で、文字列を固定長で切る処理を本番で動かしたら何が起きるかを考えてください。

この章のまとめ

  • 文字列の要素は JavaScript は16ビットの値、Python はコードポイント、Go はバイト、Rust は UTF-8 のバイト。どれも見た目の1文字ではない。
  • 要素の途中で切ると、Rust は panic、Go は不正なバイト列を保持、JavaScript は単独サロゲートを持ち UTF-8 化で U+FFFD。Python はコードポイントを切れないが、🇯🇵 を 🇯 にはできる。
  • 違いは型の約束から来る。Rust の str は常に正しい UTF-8、Go の string は任意のバイト。Go の range が1バイトずつ U+FFFD を出すのは仕様に明記された挙動。
  • 単独サロゲートを UTF-8 にするとき、JavaScript(TextEncoder)は置換、Python は拒否。同じ変換でも失敗の扱いが違う。
  • 切る処理のテストには、4バイト文字と複数コードポイントの文字を入れる。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

Python 3 で len("🇯🇵") が返す値と、その理由の組として正しいものはどれですか。

用語集(5語)
コードポイント
Unicode が文字に割り当てた番号。😀 は U+1F600。Python の str はこの列。
Unicode スカラー値
サロゲートを除くコードポイント。Unicode 17.0.0 の D76 が定義する。UTF-8 で表せるのはこれだけ。
サロゲート
UTF-16 で U+10000 以上を2つの16ビット値で表すための範囲。片方だけ残ったものを単独サロゲートと呼ぶ。
書記素クラスタ
UAX #29 が「人が1文字と見るもの」の近似として定める単位。🇯🇵 は2コードポイントで見た目1文字。
U+FFFD
置換文字(�)。不正な並びを読んだとき、または書き出せない値の代わりに入る。