できない.dev

文字列をUTF-8のバイト列にするには

文字列を UTF-8 のバイト列に変換し、元の文字列へ戻す方法を各言語で示す。
文字数とバイト数が一致しない理由と、壊れたバイト列を読んだときの挙動の違いまでを扱う。

公開:

選んだ言語は、ほかのサンプルコードを開いたときも最初に表示する。

Python

text = "あA😀"
data = text.encode("utf-8")
print(len(text), len(data))  # 3 8
print(data.hex(" "))         # e3 81 82 41 f0 9f 98 80
print(data.decode("utf-8"))  # あA😀
 
broken = data[:2]  # 「あ」の途中で切れたバイト列
try:
    broken.decode("utf-8")
except UnicodeDecodeError as e:
    print(e.reason)  # unexpected end of data
print(broken.decode("utf-8", errors="replace"))  # �

文字列の encode("utf-8") でバイト列(bytes)になり、len は文字列なら文字数、バイト列ならバイト数を返す。
戻すときは decode("utf-8") を使う。
壊れたバイト列は既定で UnicodeDecodeError になり、errors="replace" を渡すと不正な部分が U+FFFD に置き換わる。

できないときPython で「UnicodeDecodeError」を解消できずファイルが読めない

JavaScript

const text = "あA😀";
const data = new TextEncoder().encode(text);
console.log([...text].length, text.length, data.length); // 3 4 8
console.log(Array.from(data, (b) => b.toString(16).padStart(2, "0")).join(" ")); // e3 81 82 41 f0 9f 98 80
console.log(new TextDecoder().decode(data));             // あA😀
 
const broken = data.slice(0, 2); // 「あ」の途中で切れたバイト列
console.log(new TextDecoder().decode(broken)); // �
try {
  new TextDecoder("utf-8", { fatal: true }).decode(broken);
} catch (e) {
  console.log(e.name); // TypeError
}

TextEncoder は常に UTF-8 でバイト列(Uint8Array)を作り、TextDecoder で戻せる。
文字列の length は UTF-16 の単位数なので、絵文字を含むと見た目の文字数より大きくなる。
TextDecoder は壊れたバイト列を既定で U+FFFD に置き換えるが、fatal: true を渡すと TypeError で止まる。

TypeScript

const text: string = "あA😀";
const data: Uint8Array = new TextEncoder().encode(text);
console.log([...text].length, text.length, data.length); // 3 4 8
console.log(Array.from(data, (b) => b.toString(16).padStart(2, "0")).join(" ")); // e3 81 82 41 f0 9f 98 80
console.log(new TextDecoder().decode(data)); // あA😀
 
const broken = data.slice(0, 2); // 「あ」の途中で切れたバイト列
console.log(new TextDecoder().decode(broken)); // �
try {
  new TextDecoder("utf-8", { fatal: true }).decode(broken);
} catch (e) {
  console.log((e as Error).name); // TypeError
}

JavaScript と同じ TextEncoder と TextDecoder を使い、戻り値は Uint8Array と型が付く。
catch の例外は unknown として扱われるため、name を読むには Error に型を絞る。
ブラウザでも Node.js でも Buffer に頼らず同じコードで動く。

Go

package main
 
import (
	"fmt"
	"unicode/utf8"
)
 
func main() {
	text := "あA😀"
	data := []byte(text)
	fmt.Println(utf8.RuneCountInString(text), len(text)) // 3 8
	fmt.Printf("% x\n", data)                            // e3 81 82 41 f0 9f 98 80
	fmt.Println(string(data))                            // あA😀
 
	broken := data[:2] // 「あ」の途中で切れたバイト列
	fmt.Println(utf8.Valid(broken))                      // false
	fmt.Println(string(broken) == "\xe3\x81")            // true(置き換えは起きず、不正なバイトがそのまま残る)
	fmt.Println([]rune(string(broken))[0] == utf8.RuneError) // true
}

Go の文字列は元から UTF-8 のバイト列なので、[]byte(text) は変換というよりバイト列としての見方を変えるだけである。
len が返すのはバイト数で、文字数は utf8.RuneCountInString で数える。
壊れたバイト列を string にしても例外にはならないため、utf8.Valid で自分で確かめる。

Rust

fn main() {
    let text = "あA😀";
    let data: &[u8] = text.as_bytes();
    println!("{} {}", text.chars().count(), data.len()); // 3 8
    let hex: Vec<String> = data.iter().map(|b| format!("{:02x}", b)).collect();
    println!("{}", hex.join(" ")); // e3 81 82 41 f0 9f 98 80
    println!("{}", std::str::from_utf8(data).unwrap()); // あA😀
 
    let broken = &data[..2]; // 「あ」の途中で切れたバイト列
    match std::str::from_utf8(broken) {
        Ok(_) => println!("ok"),
        Err(e) => println!("{}", e), // incomplete utf-8 byte sequence from index 0
    }
    println!("{}", String::from_utf8_lossy(broken)); // �
}

Rust の str も UTF-8 で保持されており、as_bytes でそのままバイト列として借りられる。
バイト列から str に戻す std::str::from_utf8 は不正だと Err を返し、String::from_utf8_lossy なら U+FFFD に置き換えて通す。
文字数は chars().count() で数え、len はバイト数である。

Java

import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
 
public class Main {
    public static void main(String[] args) {
        String text = "あA😀";
        byte[] data = text.getBytes(StandardCharsets.UTF_8);
        System.out.println(text.codePointCount(0, text.length()) + " " + text.length() + " " + data.length); // 3 4 8
        System.out.println(HexFormat.ofDelimiter(" ").formatHex(data)); // e3 81 82 41 f0 9f 98 80
        System.out.println(new String(data, StandardCharsets.UTF_8)); // あA😀
 
        byte[] broken = java.util.Arrays.copyOf(data, 2); // 「あ」の途中で切れたバイト列
        System.out.println(new String(broken, StandardCharsets.UTF_8)); // �
    }
}

getBytes(StandardCharsets.UTF_8) で文字コードを明示してバイト列にし、戻すときも同じ文字コードを new String に渡す。
引数なしの getBytes() は実行環境の既定の文字コードに結果が左右されるため避ける。
String.length() は UTF-16 の単位数で、絵文字は 2 と数えられる。
HexFormat は Java 17 以降で使える。

C#

using System;
using System.Linq;
using System.Text;
 
var text = "あA😀";
byte[] data = Encoding.UTF8.GetBytes(text);
Console.WriteLine($"{text.EnumerateRunes().Count()} {text.Length} {data.Length}"); // 3 4 8
Console.WriteLine(BitConverter.ToString(data).Replace('-', ' ').ToLower()); // e3 81 82 41 f0 9f 98 80
Console.WriteLine(Encoding.UTF8.GetString(data)); // あA😀
 
var broken = data[..2]; // 「あ」の途中で切れたバイト列
Console.WriteLine(Encoding.UTF8.GetString(broken)); // �
var strict = new UTF8Encoding(false, true);
try { strict.GetString(broken); }
catch (DecoderFallbackException e) { Console.WriteLine(e.GetType().Name); } // DecoderFallbackException

Encoding.UTF8.GetBytes でバイト列にし、Encoding.UTF8.GetString で戻す。
string.Length は UTF-16 の単位数なので、絵文字を 1 文字と数えるには EnumerateRunes を使う。
既定のデコーダーは壊れたバイト列を U+FFFD に置き換え、例外にしたいときは new UTF8Encoding(false, true) を使う。

つまずき

文字列の長さとバイト数は別の値である。
「あA😀」は 3 文字だが、UTF-8 にすると「あ」が 3 バイト、「A」が 1 バイト、「😀」が 4 バイトの計 8 バイトになった。
ファイルやネットワークに書き出すときの長さ(Content-Length やバッファの大きさなど)にはバイト数を使い、文字数と混同しない。

長さの数え方は言語で違う

同じ「あA😀」でも、length の返す値は言語で変わった。

  • Python の len(text):3(コードポイントの数)
  • JavaScript・TypeScript の text.length:4(UTF-16 の単位数。絵文字が 2 と数えられる)
  • Java の text.length()・C# の text.Length:4(同じく UTF-16 の単位数)
  • Go の len(text)・Rust の text.len():8(バイト数)

見た目どおりの 3 文字を得るには、JavaScript は [...text].length、Java は codePointCount、C# は EnumerateRunes、Go は utf8.RuneCountInString、Rust は chars().count() を使う。

壊れたバイト列を読んだとき

「あ」の 3 バイト(e3 81 82)のうち先頭の 2 バイトだけを UTF-8 として読むと、言語ごとに挙動が分かれた。

  • Python:UnicodeDecodeError(unexpected end of data)
  • Rust:from_utf8 が Err(incomplete utf-8 byte sequence from index 0)
  • JavaScript・TypeScript:既定では U+FFFD に置き換え、fatal: true で TypeError
  • Java:U+FFFD に置き換えて例外にならない
  • C#:既定では U+FFFD に置き換え、UTF8Encoding(false, true) で DecoderFallbackException
  • Go:例外も置き換えもなく、不正なバイトがそのまま残る(utf8.Valid は false)

置き換えて通す言語では、壊れたデータに気づかないまま処理が進む。
外部から受け取ったバイト列は、例外にするか検証するかを自分で選ぶ。

文字コードを取り違えると読めない

「あA」を UTF-8 にすると e3 81 82 41 の 4 バイトで、Shift_JIS(cp932)にすると 82 a0 41 の 3 バイトになった。
cp932 で作ったバイト列を Python で UTF-8 として読むと、先頭の 0x82 が不正な開始バイトとして UnicodeDecodeError になる。
逆に「😀」は cp932 で表せないため、encode("cp932") は UnicodeEncodeError(illegal multibyte sequence)になった。
受け渡す相手の文字コードを確かめ、迷ったら UTF-8 にそろえる。

このコードで動いた?