Swift Digest
SE-0177 | Swift Evolution

Add clamp(to:) to the stdlib

Proposal
SE-0177
Authors
Nicholas Maccharoli
Review Manager
Status
Returned for revision

01 何が問題だったのか

ある値を特定の範囲に収める「クランプ(clamp)」は、座標値をビューの描画領域に収めたり、計算結果を許容範囲内に丸めたりと、日常的に頻出する処理です。値が範囲内ならそのまま、下限より小さければ下限、上限より大きければ上限を返す、という典型的なパターンですが、当時の標準ライブラリには Comparable な値に対する汎用的なクランプ関数が用意されていませんでした。

範囲絞り込みが自前の if 連鎖になってしまう

たとえば座標値を 0 以上 50 以下に収めたいだけの処理でも、次のように上限と下限を個別に比較する定型コードを毎回書く必要がありました。

let clamped: Int
if value > 50 {
    clamped = 50
} else if value < 0 {
    clamped = 0
} else {
    clamped = value
}

三項演算子や min / max の組み合わせで短く書くことはできますが、どの書き方も「範囲で表される制約」と直接対応していないため、意図が伝わりにくく誤りも混入しがちです。

似た操作が CountableRange 側にだけ存在していた

一方で、標準ライブラリには CountableRangeclamped(to:) がすでに存在しており、範囲どうしを重なりに絞り込む操作としてよく使われていました。値側にも同じ発想の clamped(to:) が欲しくなるのは自然なのですが、そのAPIは提供されておらず、概念的な一貫性が欠けていました。値を Comparable の範囲に収める操作も、同じ clamped(to:) という名前で書けるべきだという動機が、この提案の背景です。

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

ComparableStrideable のプロトコル拡張として clamped(to:) メソッドを追加し、任意の Comparable な値を範囲に収められるようにします。値が範囲内ならそのまま、下限より小さければ下限、上限より大きければ上限が返ります。

ClosedRange でクランプする

Comparable 拡張として、ClosedRange<Self> を受け取る clamped(to:) が提供されます。

extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        if self > range.upperBound {
            return range.upperBound
        } else if self < range.lowerBound {
            return range.lowerBound
        } else {
            return self
        }
    }
}

使い方は直感的で、範囲内なら値そのもの、範囲外なら近い方の境界値が返ります。

100.clamped(to: 0...50)    // 50
100.clamped(to: 200...300) // 200
100.clamped(to: 0...150)   // 100

半開区間 Range でクランプする(Strideable

半開区間 Range<Self> でクランプする版は、Stride が整数であるような Strideable の拡張として提供されます。内部では upperBound - 1 を上限とする閉区間に変換してからクランプするため、到達不能な upperBound ではなく「その直前の値」に収まります。

extension Strideable where Stride: Integer {
    func clamped(to range: Range<Self>) -> Self {
        let clampRange: ClosedRange<Self>
        if range.lowerBound == range.upperBound {
            clampRange = range.lowerBound...range.upperBound
        } else {
            clampRange = range.lowerBound...(range.upperBound - 1)
        }
        return clamped(to: clampRange)
    }
}

半開区間版の挙動は次の通りです。

100.clamped(to: 0..<50)    // 49
100.clamped(to: 200..<300) // 200
100.clamped(to: 0..<150)   // 100
100.clamped(to: 42..<42)   // 42

最後の例のように下限と上限が等しい「空の範囲」を渡した場合でも、その境界値(この例では 42)が返るように定義されています。

このProposalの現状

このProposalは Returned for revision となっており、提案された形のままでは標準ライブラリに取り込まれていません。Strideable 拡張の制約(Stride: Integer の前提や半開区間に対する意味づけ)や、浮動小数点数を含む他の Comparable 型との整合性など、詰めるべき設計上の論点が残されていたためです。ダイジェストとしては、この提案が取り扱おうとしていた「Comparable な値に clamped(to:) を提供する」という問題意識と、閉区間・半開区間それぞれに対する具体的な挙動の案を押さえておくと、今後同種の提案を読む際の土台になります。