Dartの数値型を極限まで理解する:64bit整数の壁、ビット演算の罠、そしてBigIntの正しい処方箋
コードレビューをしていて、最もゾッとする瞬間のひとつがこれだ。
// 外部APIから取得した巨大なIDや、フロントエンドでのタイムスタンプ演算
final parsedId = int.parse(jsonResponse[‘id’]);
Webフロントエンド開発(Flutter Web / Dart SPA)や、バックエンドとの強固なAPI連携を行うプロジェクトにおいて、数値型のプリミティブな挙動を甘く見ることは、いつ爆発するか分からない地雷原を歩いているに等しい。特にJavaScriptへのトランスパイルが発生するWeb環境において、Dartの数値型は、VM(AOT/JIT)とV8エンジンの間で特有の挙動を示す。
今回は、Dartの `int` がメモリ上でどう扱われ、ビット演算時に何を引き起こすのか。そして、64bitの限界を超える巨大数(BigInt)とどう向き合うべきか。テクニカルリードの視点から、プロダクションコードで絶対に事故を起こさないための知見を叩き込む。
—
1. Dartの `int` 型の正体:VMとWebターゲットの二面性
Dartの `int` 型を語る上で避けて通れないのが、「プラットフォームによる表現力の違い」だ。
- AOT / JIT コンパイル環境 (Flutter Mobile, Desktop, Server):
完全に符号付き 64bit整数(`-2^63` から `2^63 – 1`)としてメモリ上に格納される。C言語の `int64_t` と同等だ。
- Web環境 (Dart to JS 編成):
JavaScriptの仕様(IEEE 754倍精度浮動小数点数)に引きずり下ろされるため、安全に扱える整数は 53bit(`-(2^53 – 1)` から `2^53 – 1`、すなわち `Number.MAX_SAFE_INTEGER`)に制限される。
[ネイティブ環境 (VM)]
[ 64-bit 整数 ] =======================================> 巨大な範囲をカバー
[Web環境 (JS)]
[ 53-bit 整数 (IEEE 754) ] =====> これを超えると精度が丸められ、ビット演算がおかしくなる
もしあなたがFlutter WebやDartのWebアプリで、Twitter(現X)のSnowflake IDや、64bit幅のビットフラグを直接扱っているなら、Webブラウザ上で暗黙的な精度の損失(Precision Loss)が起きている可能性を疑うべきだ。
—
2. ビット演算(``, `&`, `|`, `^`, `~`)の暗黒面
Dartのビット演算子は、内部的には64bit(Webでは32bit符号付き整数に一旦キャストされる!)として処理される。ここで多くの開発者がハマるのが、符号ビットの扱いとシフト演算のオーバーフローだ。
特に危険なのが、符号なし右シフト (`>>>`) が長らくDartに存在しなかった歴史的経緯もあり、論理シフトと算術シフトの混同によるバグである。
危険なコード例:権限管理のビットフラグ操作
// 悪い例:安易なintによるビット演算
class UserPermissions {
int flags;
UserPermissions(this.flags);
// 読み取り権限の付与
void grantRead() {
flags |= (1 << 0);
}
// 特定の巨大なフラグチェック (仮に32bit目以上を操作する場合)
bool checkEnterpriseFeature() {
// Web環境でコンパイルされた場合、32bitを超えるビット演算は
// ビットワイズNOTやシフトで挙動が破綻することがある
return (flags & (1 << 35)) != 0;
}
}
なぜこれが非効率かつ危険なのか?
Webターゲット(JS出力)において、ビット演算(`|`, `&`, `<<` 等)は、オペランドを強制的に 32bit符号付き整数 に変換してから演算を実行する。つまり、ネイティブでは64bit動いていたコードが、Webにビルドした瞬間に上位ビットが吹き飛び、全く異なる挙動(サイレントバグ)を引き起こす。
—
3. BigIntの正しい処方箋:いつ、どう使うべきか
では、64bitの限界を超える数値や、Web環境でも安全にビット演算や正確な演算を行いたい場合はどうすべきか。答えは `BigInt` の採用だ。
ただし、`BigInt` にはパフォーマンス上のコストが伴う。`BigInt` はプリミティブ型ではなく、ヒープ上にアロケートされるオブジェクトであるため、大量の演算をループ内で行うようなコンポーネント設計ではGC(ガベージコレクション)の圧迫を招く。
使い分けの黄金律
1. 通常のカウンター、ID(一般的なDBのオートインクリメント)、通常の数える処理: `int`
2. 暗号学的ハッシュ、Webでの巨大ID(Snowflake ID等)、正確な64bit以上のビットマスク操作: `BigInt`
—
4. プロダクションコード:堅牢な「64bit/巨大数」安全ハンドラー
ここでは、実務の現場でそのまま再利用できる、暗号化トークンやIDのパース、安全なビット演算をカプセル化したユーティリティクラスを提示する。Web/ネイティブの差異を抽象化し、予期せぬ丸め誤差を防ぐ設計だ。
import ‘dart:typed_data’;
/// 64bit整数および巨大数の安全な操作をカプセル化したユーティリティ
/// Web環境とネイティブ環境の差異を吸収し、精度の喪失を防ぐ。
class SafeNumberUtil {
/// 安全なJavaScript整数の上限 (2^53 – 1)
static const int maxSafeJsInt = 9007199254740991;
static const int minSafeJsInt = -9007199254740991;
/// 文字列から安全にintまたはBigIntをパースする
/// 巨大なID(例: 19桁のTwitter IDなど)は自動的にBigIntに昇格させる
static Object parseFlexible(String valueStr) {
// まずBigIntとしてパースを試みる
final bigIntValue = BigInt.tryParse(valueStr);
if (bigIntValue == null) {
throw FormatException(‘Invalid number format: $valueStr’);
}
// ネイティブかつJSの安全範囲内であれば通常のintを返す
// 厳密には環境依存だが、安全のためJSの限界値と比較
if (bigIntValue >= BigInt.from(minSafeJsInt) &&
bigIntValue <= BigInt.from(maxSafeJsInt)) {
return bigIntValue.toInt();
}
// 範囲外の巨大数はBigIntをそのまま返却し、精度の丸めを防ぐ
return bigIntValue;
}
/// BigIntを用いた安全なビットマスク判定(Webでも破綻しない)
/// [target]: 対象の数値(BigInt)
/// [bitPosition]: チェックしたいビットの位置 (0オリジン)
static bool checkBitBigInt(BigInt target, int bitPosition) {
final mask = BigInt.one << bitPosition;
return (target & mask) != BigInt.zero;
}
/// 64bit整数をバイト列(Uint8List)に変換し、ネットワーク層(gRPC / WebSocket)へ安全に送出する
static Uint8List int64ToBytes(int value) {
final buffer = ByteData(8);
// エンディアンを明示的に指定し、プラットフォーム依存のバグを防ぐ
buffer.setInt64(0, value, Endian.big);
return buffer.buffer.asUint8List();
}
/// バイト列から64bit整数を復元する
static int bytesToInt64(Uint8List bytes) {
if (bytes.length < 8) {
throw ArgumentError('Bytes length must be at least 8 for Int64.');
}
final buffer = ByteData.sublistView(bytes);
return buffer.getInt64(0, Endian.big);
}
}
// ==========================================
// 使用例(テストコード / 脳内トレース用)
// ==========================================
void main() {
// 1. 巨大ID(JSの安全な上限を超える文字列)のパース
const snowflakeIdStr = '1789234567890123456';
final parsedId = SafeNumberUtil.parseFlexible(snowflakeIdStr);
if (parsedId is BigInt) {
print('[Info] 巨大数として安全に保持されました: $parsedId');
} else {
print('[Info] 通常のintとして保持されました: $parsedId');
}
// 2. BigIntベースのビット演算
final flags = BigInt.from(0x01) << 40; // 40ビット目にフラグを立てる
final hasFlag = SafeNumberUtil.checkBitBigInt(flags, 40);
print('[Info] 40ビット目のフラグ状態: $hasFlag'); // true
// 3. バイナリシリアライゼーション
const originalVal = 987654321098765;
final bytes = SafeNumberUtil.int64ToBytes(originalVal);
final restoredVal = SafeNumberUtil.bytesToInt64(bytes);
print('[Info] 復元された値の整合性: ${originalVal == restoredVal}'); // true
}
---
5. チーフアーキテクトからの最終提言
Dartにおける数値操作は、一見すると非常にシンプルに記述できるため、言語の背後にある「コンパイルターゲット(VM vs V8)」の構造的違いを忘れがちだ。
- APIレスポンスのID は常に `String` として受け取るか、フロントエンドで扱うなら最初から `BigInt` または `String` で保持する設計に倒すこと。JSONの `number` 型にパースされた時点で、Web環境ではすでに精度が死んでいることがある。
- ビット演算 を複雑なドメインロジック(特に権限管理やプロトコルパーサー)で使う場合は、プラットフォーム依存の32bit/64bitの罠を排除するため、`BigInt` によるビットマスク操作を強制するアーキテクチャルールをチームで徹底してほしい。
コードは嘘をつかない。だが、コンパイラとプラットフォームの仕様を理解していない人間の書いたコードは、特定の環境で静かに沈没する。この知見を今日のコードレビューから役立ててほしい。