できない.dev

バイト列を16進数の文字列にするには

文字列を UTF-8 のバイト列にして 16 進数の文字列へ変換し、元の文字列へ戻す方法を各言語で示す。
桁のそろえ方、大文字と小文字の違い、不正な入力を渡したときの挙動の差までを扱う。

公開:

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

Python

text = "Aあ\n"
data = text.encode("utf-8")
hex_str = data.hex()
print(hex_str)                                    # 41e381820a
print(data.hex(" "))                              # 41 e3 81 82 0a
print(bytes.fromhex(hex_str).decode("utf-8") == text)  # True
print(format(10, "02x"), hex(10))                 # 0a 0xa
 
try:
    bytes.fromhex("zz")
except ValueError as e:
    print(e)  # non-hexadecimal number found in fromhex() arg at position 0

bytes.hex() でバイト列を小文字の 16 進数文字列にでき、区切り文字を渡せば 41 e3 81 82 0a のように読みやすく整形できる。
戻すときは bytes.fromhex を使い、不正な文字を渡すと ValueError で止まるため、壊れた入力に気づける。

JavaScript

const text = "Aあ\n";
const data = Buffer.from(text, "utf8");
const hex = data.toString("hex");
console.log(hex); // 41e381820a
console.log(Buffer.from(hex, "hex").toString("utf8") === text); // true
 
// Buffer を使えないブラウザでは、1 バイトずつ 2 桁にそろえて連結する
const hex2 = Array.from(new TextEncoder().encode(text), (b) => b.toString(16).padStart(2, "0")).join("");
console.log(hex2 === hex);   // true
console.log((10).toString(16)); // a(桁がそろわない)
 
console.log(Buffer.from("zz", "hex").length);  // 0(例外にならず空になる)
console.log(Buffer.from("41e", "hex").length); // 1(端数の桁は黙って捨てられる)

Node.js では Buffer の toString("hex") と Buffer.from(hex, "hex") で往復できる。
ブラウザには Buffer が無いので、toString(16) の結果を padStart(2, "0") で 2 桁にそろえて連結する。
Buffer.from は不正な 16 進数を例外にせず、黙って空や短い結果にする点に注意する。

TypeScript

const text: string = "Aあ\n";
const data: Uint8Array = new TextEncoder().encode(text);
const hex: string = Array.from(data, (b) => b.toString(16).padStart(2, "0")).join("");
console.log(hex); // 41e381820a
 
const pairs: string[] = hex.match(/../g) ?? [];
const back: Uint8Array = Uint8Array.from(pairs, (h) => parseInt(h, 16));
console.log(new TextDecoder().decode(back) === text); // true
console.log((10).toString(16)); // a(padStart を付けないと桁がそろわない)
 
console.log(parseInt("zz", 16)); // NaN(例外にならない)
console.log(Uint8Array.from(["zz"], (h) => parseInt(h, 16))[0]); // 0(NaN は 0 のバイトになる)

TextEncoder と toString(16) と padStart だけで書くので、Buffer に頼らずブラウザと Node.js の両方で動く。
2 桁ずつに分けて parseInt(h, 16) で戻せるが、不正な文字は NaN になり、Uint8Array に入れると 0 のバイトに化けるため、必要なら自分で検証する。

Go

package main
 
import (
	"encoding/hex"
	"fmt"
)
 
func main() {
	text := "Aあ\n"
	hexStr := hex.EncodeToString([]byte(text))
	fmt.Println(hexStr) // 41e381820a
 
	data, err := hex.DecodeString(hexStr)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(data) == text) // true
 
	_, err = hex.DecodeString("zz")
	fmt.Println(err) // encoding/hex: invalid byte: U+007A 'z'
	_, err = hex.DecodeString("41e")
	fmt.Println(err) // encoding/hex: odd length hex string
}

標準ライブラリの encoding/hex の EncodeToString と DecodeString で往復できる。
DecodeString は不正な文字や奇数桁を error として返すので、戻り値の err を必ず確かめる。

Rust

use std::fmt::Write;
 
fn main() {
    let text = "Aあ\n";
    let mut hex = String::new();
    for b in text.as_bytes() {
        write!(hex, "{:02x}", b).unwrap();
    }
    println!("{}", hex); // 41e381820a
 
    let data: Vec<u8> = (0..hex.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
        .collect();
    println!("{}", String::from_utf8(data).unwrap() == text); // true
    println!("{:x} {:02x}", 10, 10); // a 0a
 
    match u8::from_str_radix("zz", 16) {
        Ok(_) => println!("ok"),
        Err(e) => println!("{}", e), // invalid digit found in string
    }
}

標準ライブラリには 16 進数の変換関数が無いため、この例では format! と同じ書式指定の {:02x} で 1 バイトずつ 2 桁にそろえて連結した。
戻すときは 2 文字ずつ u8::from_str_radix(…, 16) で読み、不正な文字は Err になる。

Java

import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
 
public class Main {
    public static void main(String[] args) {
        String text = "Aあ\n";
        byte[] data = text.getBytes(StandardCharsets.UTF_8);
        String hex = HexFormat.of().formatHex(data);
        System.out.println(hex); // 41e381820a
        System.out.println(HexFormat.of().withUpperCase().formatHex(data)); // 41E381820A
 
        byte[] back = HexFormat.of().parseHex(hex);
        System.out.println(new String(back, StandardCharsets.UTF_8).equals(text)); // true
 
        try {
            HexFormat.of().parseHex("zz");
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage()); // not a hexadecimal digit: "z" = 122
        }
    }
}

HexFormat の formatHex と parseHex で往復できる。
出力は既定で小文字で、withUpperCase() を付けると大文字になる。
不正な文字は IllegalArgumentException になる。
HexFormat は Java 17 以降で使える。

C#

using System;
using System.Text;
 
var text = "Aあ\n";
byte[] data = Encoding.UTF8.GetBytes(text);
string hex = Convert.ToHexString(data);
Console.WriteLine(hex);                    // 41E381820A(大文字で返る)
Console.WriteLine(hex.ToLowerInvariant()); // 41e381820a
 
byte[] back = Convert.FromHexString(hex);
Console.WriteLine(Encoding.UTF8.GetString(back) == text); // True
 
try { Convert.FromHexString("zz"); }
catch (FormatException e) { Console.WriteLine(e.GetType().Name); } // FormatException
try { Convert.FromHexString("41e"); }
catch (FormatException e) { Console.WriteLine(e.GetType().Name); } // FormatException

Convert.ToHexString と Convert.FromHexString で往復できる。
ToHexString は大文字で返すため、小文字にしたいときは ToLowerInvariant を通す。
不正な文字や奇数桁は FormatException になる。

つまずき

16 進数の文字列は、バイト列を 1 バイトにつき 2 桁で書き並べたものである。
「Aあ」と改行を UTF-8 にすると 41 e3 81 82 0a の 5 バイトで、連結すると 41e381820a になった。
ここで 0a のように上の桁が 0 になるバイトを「a」と 1 桁で出力してしまうと、全体の桁が合わず、元に戻せなくなる。
数値を 16 進数にする toString(16) や hex() は桁をそろえてくれないので、2 桁にそろえる処理を自分で足す必要がある。

桁をそろえる書き方は言語で違う

同じ 10(0x0a)でも、素直に 16 進数にした結果は言語ごとに違った。

  • Python:hex(10) は 0xa(接頭辞が付き、桁もそろわない)。format(10, "02x") で 0a になる
  • JavaScript・TypeScript:(10).toString(16) は a。padStart(2, "0") を足して 0a にする
  • Rust:{:x} は a、{:02x} で 0a

Go の hex.EncodeToString、Java の HexFormat、C# の Convert.ToHexString、Python の bytes.hex() は最初から 2 桁ずつ出力するので、自分でそろえる必要がない。
バイト列全体を変換するときは、これらの専用の関数を使うほうが安全である。

大文字と小文字の違い

出力の大文字と小文字も言語で違った。
41e381820a と小文字で返すのは Python・JavaScript・Go・Rust・Java で、C# の Convert.ToHexString だけは 41E381820A と大文字で返した。
16 進数の文字列を比較するときは、大文字と小文字をそろえてから比べる。
Java は withUpperCase() で大文字に切り替えられ、C# は ToLowerInvariant で小文字にそろえられる。

不正な入力を渡したとき

16 進数の文字列を戻すとき、zz のように 16 進数にならない文字を渡すと、言語ごとに挙動が分かれた。

  • Python:ValueError(non-hexadecimal number found in fromhex() arg at position 0)
  • Go:error を返す(encoding/hex: invalid byte: U+007A 'z'。桁が奇数のときは odd length hex string)
  • Rust:from_str_radix が Err(invalid digit found in string)
  • Java:IllegalArgumentException(not a hexadecimal digit: "z" = 122)
  • C#:FormatException(桁が奇数のときも同じ)
  • JavaScript:Buffer.from("zz", "hex") は例外にならず長さ 0 のバッファになり、41e のように桁が奇数なら端数の e を捨てて 1 バイトになった
  • TypeScript:parseInt("zz", 16) は NaN で、Uint8Array に入れると 0 のバイトになった

黙って通す書き方では、壊れた入力に気づかないまま処理が進む。
外部から受け取った 16 進数の文字列は、長さが偶数か、16 進数の文字だけかを先に検証してから戻す。

このコードで動いた?