2〜36 進数を相互変換するツールを作った。「
parseInt(s, 16)でいいのでは?」と思うかもしれないが、まともに作ると 2 つの落とし穴がある: (1)Numberは 2^53 を超えると精度が壊れるので大きい数は BigInt が要る、(2) 小数部は、ある基数で割り切れても別の基数では循環する (10 進の0.1は 2 進で0.0(0011)と無限に循環)。この循環をどう検出して表示するかが本題。完全ブラウザ完結・vanilla JS。
🌐 デモ: https://sen.ltd/portfolio/base-converter/
📦 GitHub: https://github.com/sen-ltd/base-converter
hinge 1: 整数部は BigInt で桁数無制限
parseInt("ff", 16) は確かに 255 を返す。でも大きい数だと壊れる:
Number.parseInt("ffffffffffffffff", 16) // 18446744073709552000 (間違い!)
// 正しくは 18446744073709551615
Number は float64 なので 2^53 を超えると整数を正確に表せない。基数変換ツールとしては致命的。BigInt で実装する:
const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";
// 文字列 → BigInt
export function parseIntPart(intStr, base) {
const b = BigInt(base);
let acc = 0n;
for (const ch of intStr) {
acc = acc * b + BigInt(digitValue(ch));
}
return acc;
}
// BigInt → 文字列
export function renderIntPart(value, base) {
if (value === 0n) return "0";
const b = BigInt(base);
let out = "";
let v = value;
while (v > 0n) {
out = DIGITS[Number(v % b)] + out;
v = v / b;
}
return out;
}
これで桁数の上限が消える。テストで 200bit を確認:
test("200-bit number converts exactly", () => {
const bin = "1" + "0".repeat(200); // 2^200
assert.equal(renderIntPart(parseIntPart(bin, 2), 10),
"1606938044258990275541962092341162602522202993782792835301376");
});
v % b の結果を Number() で添字にしているが、b は最大 36 なので剰余は必ず 0〜35。ここだけは安全に Number 化できる。
hinge 2: 小数部の循環を検出する
ここが面白いところ。ある基数で「割り切れる」小数が、別の基数では「循環」する。
- 10 進の
0.1= 2 進で0.0001100110011...(0011が無限循環) - 10 進の
0.5= 2 進で0.1(ちょうど終わる)
なぜか? 0.1 = 1/10。分母 10 = 2×5 に 5 の因子があるが、2 進数は分母が 2 の冪のときしか割り切れない。だから 5 が残って循環する。
循環の検出: 剰余を覚えておく
小数部の基数変換は「変換先の基数を掛けて整数部を取り出す」の繰り返し。このとき剰余 (remainder) が再登場したら、そこから循環している。筆算の循環小数検出と同じ原理:
export function convertFraction(fracStr, fromBase, toBase, maxDigits = 64) {
// 小数部を厳密に num/den (BigInt) として持つ
const fb = BigInt(fromBase);
let num = 0n, den = 1n;
for (const ch of fracStr) {
num = num * fb + BigInt(digitValue(ch));
den = den * fb;
}
// 約分 (剰余の管理を正準にするため)
const g = gcd(num, den);
if (g > 0n) { num /= g; den /= g; }
const tb = BigInt(toBase);
const digits = [];
const seen = new Map(); // 剰余 → 出現した桁位置
let rem = num;
let repeatStart = -1;
while (rem !== 0n && digits.length < maxDigits) {
if (seen.has(rem)) { // この剰余は前に見た = 循環開始
repeatStart = seen.get(rem);
break;
}
seen.set(rem, digits.length);
rem *= tb;
digits.push(DIGITS[Number(rem / den)]); // 次の桁
rem %= den;
}
return { digits: digits.join(""), repeatStart };
}
ポイントは小数を num/den の分数として BigInt で厳密に持つこと。浮動小数点で計算すると誤差が入って循環検出が壊れる。BigInt の分数演算なら、剰余が正確に一致するので循環節を確実に見つけられる。
出力は循環節を括弧で囲む:
test("0.1 dec → repeating binary, wrapped in parens", () => {
assert.deepEqual(convert("0.1", 10, 2), { value: "0.0(0011)", repeating: true });
});
0.0(0011) の (0011) が無限に繰り返す部分。デモでは 10 進 0.1 が 2 進 0.0(0011)、8 進 0.0(6314)、16 進 0.1(9) と、基数ごとに違う循環をするのが一目で見える。
なぜ約分するのか
convertFraction の頭で gcd 約分しているのは、剰余の一致判定を正準化するため。約分しないと同じ値が 2/20 と 1/10 のように複数表現を持ち、剰余ベースの循環検出が正しく動かないことがある。最初に既約分数にしておけば、剰余は値に対して一意になる。
入力検証
基数 N では使える桁が 0 〜 (N-1 番目の文字) に限られる。2 進数に 2 があったら不正:
export function isValidForBase(str, base) {
const body = str.startsWith("-") ? str.slice(1) : str;
if (body === "" || body === ".") return false;
let seenDot = false;
for (const ch of body) {
if (ch === ".") {
if (seenDot) return false; // 小数点は 1 個まで
seenDot = true;
continue;
}
const v = digitValue(ch);
if (v < 0 || v >= base) return false; // 基数外の桁
}
return true;
}
UI ではエラー時に「使える桁: 0123456789abcdef」のように具体的に出す。
設計
convert.js — parse/render (BigInt)、小数の基数変換 + 循環検出 (DOM-free, 38 tests)
app.js — UI glue、主要基数の一覧テーブル
対応基数 2〜36 (0-9 + a-z)、負数・小数対応。convert.js は DOM 非依存なので 38 個のテストが全部 Node で走る。
試してみる
0.1 を入れて出力基数を 2 / 8 / 16 と変えてみてほしい。どの基数でも循環すること、循環節が違うことが見える。整数で 2^100 級の巨大な数を入れても精度が壊れないのも確認できる。
まとめ
- 基数変換の整数部は BigInt で。
Numberは 2^53 超で精度が壊れる。v % baseの添字化だけは base≤36 なので安全にNumber()。 - 小数部はある基数で割り切れても別基数で循環する。分母に変換先基数の素因数以外が残ると循環。
- 循環検出は剰余の再登場を
Mapで記録。筆算の循環小数と同じ原理。 - 小数を
num/denの BigInt 分数で厳密に持てば、浮動小数点誤差なしで循環節を見つけられる。 - 最初に gcd 約分して剰余を正準化すると検出が安定する。
これは SEN 合同会社の OSS ポートフォリオ #269 です。https://sen.ltd/portfolio/
