unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
api-designerror-handlingtype-systemsrefactoring

Implement clamp_to

Project: rust-lang/rust | Language: Rust | Concepts: api-design, error-handling, type-systems, refactoring | Difficulty: mid
Link: https://github.com/rust-lang/rust/pull/150075

The problem

Rust's standard library provides methods like min, max, and clamp to restrict values within certain bounds. However, there was no unified ergonomic API to restrict a value to a range using Rust's range syntax (e.g., ..=, .., a..=b) in a generic way. Additionally, handling of floating-point edge cases (like NaN bounds) and trait constraints for such an API needed careful design.

Why it's non-trivial

A naive implementation might:

  • Only work for a specific type or range kind, not generically for all orderable types and all relevant range types.
  • Fail to handle exclusive ranges or floating-point NaN edge cases correctly.
  • Not provide a trait-based extension point for custom types or future extensibility.
  • Overlook const-evaluation support and feature gating for unstable APIs.

The approach

The PR introduces a new trait and method, implements it for relevant types, and adds robust error handling for floating-point cases.


1. Introduce the ClampBounds trait
This trait allows types to implement a generic "clamp to range" operation:

+/// Trait for ranges supported by [`Ord::clamp_to`].
+#[unstable(feature = "clamp_bounds", issue = "147781")]
+#[rustc_const_unstable(feature = "clamp_bounds", issue = "147781")]
+pub const trait ClampBounds<T>: Sized {
+    /// The implementation of [`Ord::clamp_to`].
+    fn clamp(self, value: T) -> T
+    where
+        T: [const] Destruct;
+}

This trait is unstable and const-friendly, allowing a value to be clamped to a range. It's designed to be implemented for standard range types.


2. Implement ClampBounds for standard range types
For each range type, implement how clamping should work:

+const impl<T> ClampBounds<T> for RangeFrom<T>
+where
+    T: [const] Ord,
+{
+    fn clamp(self, value: T) -> T
+    where
+        T: [const] Destruct,
+    {
+        value.max(self.start)
+    }
+}

For RangeFrom, the value is clamped to be at least the start.

+const impl<T> ClampBounds<T> for RangeToInclusive<T>
+where
+    T: [const] Ord,
+{
+    fn clamp(self, value: T) -> T
+    where
+        T: [const] Destruct,
+    {
+        value.min(self.end)
+    }
+}

For RangeToInclusive, the value is at most the end.

+const impl<T> ClampBounds<T> for RangeInclusive<T>
+where
+    T: [const] Ord,
+{
+    fn clamp(self, value: T) -> T
+    where
+        T: [const] Destruct,
+    {
+        let (start, end) = self.into_inner();
+        value.clamp(start, end)
+    }
+}

For inclusive ranges, clamp between both bounds.

+const impl<T> ClampBounds<T> for RangeFull {
+    fn clamp(self, value: T) -> T {
+        value
+    }
+}

For .., do nothing.


3. Special handling for floating-point types
Floating-point clamping must panic if any bound is NaN, matching the behavior of clamp:

+macro impl_for_float($t:ty) {
+    ...
+    const impl ClampBounds<$t> for RangeFrom<$t> {
+        fn clamp(self, value: $t) -> $t {
+            assert!(!self.start.is_nan(), "start was NaN");
+            value.max(self.start)
+        }
+    }
+    ...
+    const impl ClampBounds<$t> for RangeInclusive<$t> {
+        fn clamp(self, value: $t) -> $t {
+            let (start, end) = self.into_inner();
+            assert!(start <= end, "start > end, or either was NaN");
+            value.clamp(start, end)
+        }
+    }
+}

This macro ensures that for floating-point ranges, NaN bounds cause a panic, preventing subtle bugs.


4. Add the clamp_to method to Ord and float types
The method delegates to the trait, allowing ergonomic usage:

+    #[must_use]
+    #[inline]
+    #[unstable(feature = "clamp_to", issue = "147781")]
+    fn clamp_to<R>(self, range: R) -> Self
+    where
+        Self: Sized + [const] Destruct,
+        R: [const] ClampBounds<Self>,
+    {
+        range.clamp(self)
+    }

Now any type implementing Ord can use .clamp_to(range).


5. Add tests for edge cases
Tests ensure correct panic behavior for invalid bounds, especially with floats:

+float_test! {
+    name: clamp_to_min_is_nan,
+    attrs: {
+        const: #[cfg(false)],
+        f16: #[should_panic, cfg(target_has_reliable_f16_math)],
+        f32: #[should_panic],
+        f64: #[should_panic],
+        f128: #[should_panic, cfg(target_has_reliable_f128_math)],
+    },
+    test {
+        let _ = Float::ONE.clamp_to(Float::NAN..=1.0);
+    }
+}

This test ensures that passing NaN as a bound panics as expected.


From the review thread

joshtriplett: @Kyuuhachi I responded in the two threads that remain open. With those two addressed, r=me.


tgross35:
It is supported reasonably well on wasm, but everything with LLVM and f128 libm functions is borked right now on all platforms except in specific circumstances (basically only glibc) https://github.com/llvm/llvm-project/issues/44744. The target_has_reliable_f128_math gate is meant to guard this, as opposed to target_has_reliable_f128 for the basic functionality which works well.


Takeaways

  • Design generic APIs using traits to support multiple types and future extensibility.
  • Always handle edge cases explicitly, especially for types like floats where NaN and invalid bounds can cause subtle bugs.
  • Feature gating and const support are important for evolving standard library APIs safely and ergonomically.
  • Comprehensive testing, including for error conditions, is essential when extending core language features.