Swift Digest
SE-0050 | Swift Evolution

Decoupling Floating Point Strides from Generic Implementations

Proposal
SE-0050
Authors
Erica Sadun, Xiaodi Wu
Review Manager
Chris Lattner
Status
Withdrawn

01 何が問題だったのか

Swift の stride(from:to:by:) / stride(from:through:by:) は、整数型にも浮動小数点型にも同じジェネリックな実装で対応しており、Strideable プロトコルを通じて統一的に扱われています。しかしこの「単一の実装ですべてを賄う」方針は、浮動小数点数に対しては精度の面で問題を抱えていました。

誤差が累積する

ジェネリック実装の stride は、次の値を「前の値に by を足す」形で生成します。浮動小数点数では、この加算を繰り返すたびに丸め誤差が少しずつ積み重なり、反復の後半ほど理想値からのずれが大きくなっていきます。

以下は Swift 2.2 時点のコード例です。

let ideal = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0]
print(zip(Array(1.0.stride(through: 2.01, by: 0.1)), ideal).map(-))
// [0.0, 0.0, 2.22e-16, 2.22e-16,
//  4.44e-16, 4.44e-16, 4.44e-16,
//  6.66e-16, 6.66e-16, 8.88e-16,
//  8.88e-16]

理想値からのずれが、反復が進むにつれて大きくなっていく様子がわかります。これはジェネリック実装のアルゴリズム上避けられない挙動で、C スタイルの for ループで同じことをやっても同様の問題が起きます。

終端の扱いが直感に反する

上記の例で through:2.0 ではなく 2.01 を渡しているのも、同じ誤差の影響です。累積誤差によって最後の値が 2.0 をわずかに超えてしまい、2.0 までで止めたつもりが 1.9 付近で終わってしまう、という現象が起きます。そのため、終端を確実に含めたい場合にユーザ側で小さな補正値を足すという、直感的でない書き方を強いられていました。

根本原因は「整数と同じ漸化式」を使っていること

浮動小数点数の累積誤差は T(要素の型)ではなく T.Stride(刻み幅の型)に由来する性質であり、刻み幅が浮動小数点である限り、整数用の漸化式をそのまま流用していては回避できません。浮動小数点ストライド固有のアルゴリズムが必要、というのがこの提案の出発点です。

02 どのように解決されるのか

この提案は Withdrawn(取り下げ) となりました。ここで提示されたアイデアそのものは、後続の議論を経て別形の改善へとつながっていますが、この提案の構文・型は Swift には入っていません。以下では「もし採択されていたら何がどう変わるはずだったか」をダイジェストとして整理します。

浮動小数点専用のストライド型を追加する

既存のジェネリックな StrideTo / StrideThrough はそのまま残しつつ、T.StrideFloatingPoint に適合する場合にだけ選ばれる、専用の FloatingPointStrideTo / FloatingPointStrideThrough を追加します。つまり刻み幅が浮動小数点である限り、整数用と別の「誤差の溜まらない」実装が自動的に使われるようになる、という設計です。

誤差を貯めないための漸化式

新しい実装は、各要素を「前の値 + 刻み幅」ではなく「開始値 + ステップ数 × 刻み幅」として計算します。

// イメージ
let current = start + step * stride
step += 1

こうすると、各ステップで発生する丸め誤差が次のステップに持ち越されません。理想値に対する誤差はおおむね一定に保たれ、反復の後半ほど大きくずれていく、という挙動がなくなります。

ちょうど元の提案にあるスケッチは次のような形でした(FloatingPoint プロトコルは SE-0067 で整備されるものを前提)。

public struct FloatingPointStrideToIterator<
  Element : Strideable where Element.Stride : FloatingPoint
> : IteratorProtocol {
  internal var _step: Element.Stride = 0
  internal let _start: Element
  internal let _end: Element
  internal let _stride: Element.Stride

  public mutating func next() -> Element? {
    let current = _start + _step * _stride
    if _stride > 0 ? current >= _end : current <= _end {
      return nil
    }
    _step += 1
    return current
  }
}

使う側のトップレベル API(stride(from:to:by:) / stride(from:through:by:))のシグネチャや呼び出し方は変わらず、浮動小数点の場合にだけ内部で新しい型が返されるようになります。

最大反復回数の扱い

「開始値 + ステップ数 × 刻み幅」という式では、ステップカウンタも浮動小数点として持ちます。整数カウンタを使うと、反復ごとの整数→浮動小数点変換や 32-bit 環境での多倍長演算のコストが無視できないためです。

この設計の帰結として、Double を端点とするストライドで表現できる反復回数は 2^53 − 1 までに制限されます。これは現実のマシンで無限ループと区別がつかない規模なので、通常の用途では気にする必要はありません。刻み幅が isFinite かつ非ゼロであること、開始〜終了〜刻み幅から計算されるステップ数が範囲内に収まることは、イテレータ生成時に事前条件としてチェックされ、必要な反復回数が収まりきらない場合は実行時に失敗します。

Float については、内部計算を Double に持ち上げる派生版を別途用意するかどうかがレビュー時の論点として残されていました(Float の精度では、2^24 − 1 回を超える実用的なループで桁落ちが懸念されるため)。

スコープから外されていたもの

提案は意図的に対象を絞っており、以下はすべて別提案で扱うと整理されていました。

  • Range への striding(by:) / by(_:) の追加
  • ストライドを Sequence ではなく Collection に適合させること、および Collection(あるいは Sequence)全般に対する striding の一般化
  • to: / through: のパラメータラベルの名前の見直し
  • StrideToIterator / StrideThroughIterator の内部実装のブランチ削減
  • Range とストライド型の統合

現在の Swift での立ち位置

この提案自体は取り下げられ、ここで提示された FloatingPointStrideTo / FloatingPointStrideThrough という新しい型や、それを返す追加オーバーロードは Swift には導入されていません。ただし、「浮動小数点のストライドでは誤差を累積させない」という考え方自体は後続の議論で支持され、のちに stride の実装側が「前の値に足す」方式から「開始値 + ステップ数 × 刻み幅」方式へと書き換えられています。そのため現在の Swift では、この提案の型を明示的に使わなくても、浮動小数点の stride で累積誤差の問題は実質的に解消されています。現在のコードで本提案の構文を参照する必要はなく、あくまで「浮動小数点ストライドの精度問題をどう整理しようとしていたか」を知るための歴史的な提案として位置づけられます。