クラウド Mac で検証する iOS 整数オーバーフローと容量計算
ダウンロード処理では「チャンク数 × チャンクサイズ」で容量を確保し、画像処理では「行数 × 1 行あたりのバイト数」でバッファを確保します。通常は問題のない計算でも、破損したファイルや異常な API データが入力されると、整数オーバーフローを起こし、アプリのクラッシュにつながることがあります。クラウド Mac では、純粋な算術ロジックを Swift の検証用実行ファイルに切り出すことで、シミュレータを起動せずに、iOS プロジェクト向けの安定した境界値回帰テストを構築できます。
まず容量計算の契約を定義する
この記事で扱うのは非負整数の容量だけで、浮動小数点数による近似値は対象にしません。ルールは 3 つです。負数は不正な入力とし、Int の範囲を超えた場合はオーバーフローエラーを返し、切り上げ除算の除数は正数に限定します。容量がゼロであることは許容しますが、除数がゼロであることは許容しません。
検証する前に符号なし整数型へ変換してはいけません。負数を通常の方法で符号なし整数に変換すると、その場でトラップして終了する可能性があります。一方、切り詰め変換では、巨大な正数に変わることがあります。文字列から入力を受け取る場合も、まず Int としての解析が成功したことを確認し、解析の失敗をゼロで置き換えないようにします。
計算結果を整数型で表現できることと、その容量を確保できることは別です。整数の範囲チェックと業務上のリソース上限チェックは独立した検証であり、両方を通過して初めてリソースを確保できます。
「個数」「1 個あたりのバイト数」「合計バイト数」は、それぞれ区別できる名前にして、異なる単位の混在を防ぎます。エラーには、どの演算段階で発生したかも含めるべきですが、機密性のある生の入力を丸ごとログに書き込んではいけません。
ラップアラウンドではなく、オーバーフローを報告する演算を使う
通常の +、* は、オーバーフローすると実行時エラーになります。一方、&+、&* は明示的にラップアラウンドを行うため、極端に大きな容量が小さな値に化けることがあります。容量の検証には、計算結果とオーバーフローの有無を同時に取得できる演算が必要です。先にフラグを確認し、その後で結果を使用します。
次のコードを arithmetic.swift として保存してください。アプリのプロジェクトには依存せず、実際に大容量のメモリを確保することもありません。
enum CapacityError: Error, Equatable {
case invalidInput
case overflow
}
func checkedAdd(_ a: Int, _ b: Int) throws -> Int {
guard a >= 0, b >= 0 else {
throw CapacityError.invalidInput
}
let result = a.addingReportingOverflow(b)
guard !result.overflow else {
throw CapacityError.overflow
}
return result.partialValue
}
func checkedMultiply(_ a: Int, _ b: Int) throws -> Int {
guard a >= 0, b >= 0 else {
throw CapacityError.invalidInput
}
let result = a.multipliedReportingOverflow(by: b)
guard !result.overflow else {
throw CapacityError.overflow
}
return result.partialValue
}
func ceilingDivide(_ n: Int, by d: Int) throws -> Int {
guard n >= 0, d > 0 else {
throw CapacityError.invalidInput
}
let q = n / d
return n % d == 0 ? q : try checkedAdd(q, 1)
}
func require(_ condition: Bool) {
guard condition else {
fatalError("Boundary check failed")
}
}
func expectError(
_ expected: CapacityError,
_ operation: () throws -> Int
) {
do {
_ = try operation()
fatalError("Expected capacity error")
} catch let error as CapacityError {
require(error == expected)
} catch {
fatalError("Unexpected error type")
}
}
require(try checkedAdd(Int.max, 0) == Int.max)
require(try checkedMultiply(Int.max, 1) == Int.max)
require(try checkedMultiply(0, Int.max) == 0)
require(try ceilingDivide(0, by: 4) == 0)
require(try ceilingDivide(8, by: 4) == 2)
require(try ceilingDivide(9, by: 4) == 3)
require(try ceilingDivide(Int.max, by: 1) == Int.max)
require(try ceilingDivide(Int.max, by: 2) == Int.max / 2 + 1)
expectError(.overflow) {
try checkedAdd(Int.max, 1)
}
expectError(.overflow) {
try checkedMultiply(Int.max, 2)
}
expectError(.invalidInput) {
try checkedMultiply(-1, 0)
}
expectError(.invalidInput) {
try ceilingDivide(1, by: 0)
}
expectError(.invalidInput) {
try ceilingDivide(Int.min, by: -1)
}
ここでは、検証の順序を確認するために、あえて負数とゼロの乗算をテストしています。「乗数がゼロならゼロを返す」という早期リターンを先に実行すると、不正な入力が契約の検証をすり抜けてしまいます。テストではエラーの種類まで確認し、単に「何らかのエラーが発生した」ことだけで済ませないようにします。
切り上げ除算で先に除数を加えない
チャンク数の計算には、よく (n + d - 1) / d が使われます。数学的に正しくても、固定幅整数で安全とは限りません。最終的な商が小さくても、その前の加算で範囲を超える可能性があります。
この例では、先に商と余りを求め、余りがある場合だけ 1 を加えます。入力を非負数、除数を正数に限定しているため、符号付き整数の最小値を負の 1 で割るという、除算特有のオーバーフローも発生しません。
テストには少なくとも、ゼロ、割り切れる場合、余りが 1 の場合、整数の最大値、除数がゼロの場合を含めます。Int.max を特定の 10 進定数で置き換えてはいけません。型が提供する境界値を直接使うことで、開発者が記憶しているプラットフォームの前提ではなく、実際のコンパイルターゲットの整数幅を検証できます。
2 種類のコンパイルモードで実行する
クラウド Mac では、使用する開発ツールチェーンが選択されている必要があります。まずコンパイラを利用できることを確認し、それぞれのモードでコンパイルして実行します。
set -eu
xcrun swiftc --version
mkdir -p .arithmetic-check
xcrun swiftc -Onone arithmetic.swift \
-o .arithmetic-check/debug
.arithmetic-check/debug
xcrun swiftc -O arithmetic.swift \
-o .arithmetic-check/optimized
.arithmetic-check/optimized
成功すると、プログラムは何も出力せずに終了します。検証の失敗や未処理のエラーがあれば、そのステップは失敗します。パイプを使って、プログラムの終了コードがログツールの成功ステータスに置き換わらないようにしてください。
この例で assert を使っていないのは、最適化ビルドでは削除される可能性があるためです。独自の require は明示的な条件判定と fatalError を使っており、ここで使用するどちらのモードでもチェックが残ります。実行時の安全性チェックを弱めるような、非標準の最適化設定も追加しないでください。
アプリへの組み込み時にはリソース上限も確認する
この独立したプログラムは、算術の契約を検証するための最小構成です。実際に組み込む際は、似た関数を 2 組ずっと保守するのではなく、アプリとテストが同じ本番実装を参照するようにします。
呼び出しの流れを、次の順序で確認してください。
- 元の入力の解析に失敗したら、直ちにエラーを返す。デフォルトの容量で処理を続けない。
- 各値の単位をそろえてから、オーバーフローチェック付きの乗算・加算を行う。途中の式で先にオーバーフローしないようにする。
- 結果を製品仕様で定めた 1 回あたりの上限と比較し、確保するかどうかを判断する。
- よりビット幅の狭い整数型へ変換する場合は、失敗を検出できる厳密な変換を使い、切り詰めを許容しない。
- 失敗時の経路で、ファイルの作成、バッファの確保、後続タスクの投入が行われていないことを確認する。
最後に、「算術としては正しいが、業務上のリソース上限を超える」ケースを追加し、オーバーフローエラーとは別に扱われることを確認します。これにより、失敗の原因を調べる際に、不正な入力、型の表現範囲超過、リソースポリシーによる拒否を明確に区別でき、あらゆる境界値の問題をメモリ不足として片付けずに済みます。
よくある質問
オーバーフローしたら Int.max を返してもよいですか?
原則として明示的なエラーを返します。Int.max への置き換えは正常な大容量と失敗を区別できなくします。飽和演算を仕様として定めた場合に限り採用してください。
iOS シミュレータの起動は必要ですか?
サンプルは Swift 標準ライブラリだけで動作するため不要です。ただし、画面表示、プラットフォーム固有の型、実際のメモリ確保は別途プラットフォーム上で検証します。
ビルドは専有クラウド Mac におまかせ
Apple Silicon 搭載の物理ノードを選び、マシンを丸ごと専有できます。macOS のグラフィカル環境もコマンドラインもそのまま利用可能です。モデル、利用期間、ノードは注文内容をご確認ください。