Skip to content

ACP: Saturating conversions between signed and unsigned integers #765

@okaneco

Description

@okaneco

Proposal

Problem statement

Over the past few years, Rust has gained explicit (stable and unstable) methods to cast between integer types and communicate intent of the conversion, without the overloaded as operator being present in user code.

However, there's no way to convert between signed/unsigned integers and clamp to the target type. User-written clamp implementations are required for this kind of conversion but also introduce more opportunities for bugs.

Motivating examples or use cases

One place where this is often re-implemented is in image/RGB pixel code. Arithmetic is done in i16 or i32 and then clamped to u8.

// i32 -> u8
let result = value.max(u8::MIN as i32).min(u8::MAX as i32) as u8;
let result = value.clamp(u8::MIN as i32, u8::MAX as i32) as u8;

Mapping from u8 to i8 currently can look like this, but the intent is obscured.

// u8 -> i8
let result = value.min(i8::MAX.cast_unsigned()).cast_signed();

as casting from floats to integers today is already a lossy, saturating conversion. Clamping elements out of the target range to the target minimum or maximum can be a desired way of handling out-of-range values.

// Out of range float-to-int conversions saturate today
assert_eq!(-1.0_f32 as u8, 0u8);
assert_eq!(128.0_f64 as i8, 127i8);

Saturating integer conversion would cover point 5 from the problem statement of #204, which is the last major conversion behavior missing from the standard library for integers.

  1. keep numerical value and saturate if out of range

Solution sketch

The API is inspired by the already existing cast_signed/cast_unsigned on integers and designed to compose with the recently accepted integer truncate/extend methods from #204.

These functions are meant to bridge the gap between signed and unsigned equal-bit-width integers. From, extend<Target>, or saturating_truncate<Target> can then be used to reach the destination type.

impl {uN, usize} {
    /// Converts `self` to the signed integer of the same size, clamping
    /// `self` to the signed integer's maximum value if necessary.
    pub const fn saturating_cast_signed(self) -> iN {
        if self < <$SignedT>::MAX as $ActualT { 
            self as $SignedT
        } else {
            <$SignedT>::MAX
        }
    }
}
impl {iN, isize} {
    /// Converts `self` to the unsigned integer of the same size, clamping
    /// `self` to `0` if necessary.
    pub const fn saturating_cast_unsigned(self) -> uN {
        if self < 0 { 
            0
        } else {
            self as $UnsignedT
        }
    }
}

Thus, the motivating examples above can be rewritten as follows.

// i32 -> u8
let result = value.saturating_cast_unsigned().saturating_truncate::<u8>();
// u8 -> i8
let result = value.saturating_cast_signed();

Alternatives

Possible extensions to this proposal

Further casting functions that may be reasonable to add are strict_cast_* and checked_cast_*. These can be handled by TryInto/TryFrom traits but may be convenient to spell with explicit methods.

  • strict_cast_* - A cast that panics for integers outside of the range for the target type. This would be equivalent to value.try_into().unwrap() and follows in the spirit of the strict_ops added in Add operations for easy arithmetic that panics on overflow #270 which provide an alternative to wrapping ops.
  • checked_cast_* - Option returning cast that returns None when the source integer is out of range for the target, like value.try_into().ok().

In the comments for this proposal, unchecked and overflowing casts were suggested but I do not have any real-world use case for those.


  1. Do nothing. Users can still write manual conversions or use third-party crates.
  2. Implement a more powerful cast_saturating which could freely cast between any of the fixed-width integer types. This has the drawbacks of covering many lossless From conversions (unsigned to larger signed and unsigned types) and ignoring the truncate/extend API. The functionality is useful but a bit too magical to live in std.
  3. Pursue trait implementations like the TryLossy or other RFCs.

Links and related work

Other languages

  • P0543R3: Saturation arithmetic - accepted C++26 paper for saturating add, sub, multiplication, division, and casting for signed and unsigned integers
  • P4052R0: Renaming saturation arithmetic functions - accepted C++26 paper for renaming the saturating operations to match Rust's saturating_* convention and to consider that scheme (overflowing_, widening_, wrapping_, etc.) for future proposals.
    • add_sat becomes saturating_add, saturate_cast becomes saturating_cast.

RFCs

Traits for lossy conversions - rust-lang/rfcs#3415

ACPs

Integer extension and truncation methods - #204
strict_* arithmetic ops that panic on overflow - #270
cast_signed/cast_unsigned - #359

Crates

These crates offer solutions like Alternative 2 listed above. Both crates allow users to implement the saturating behavior for their own types so they still have use cases even if this proposal is accepted.

  • az - saturating_as

Provides saturating casts between integers as well as floats and integers. However, this crate chose to panic when a float is NAN instead of the saturating behavior eventually chosen by the language.

use az::SaturatingAs;
assert_eq!((-1).saturating_as::<u32>(), 0);
assert_eq!((17.0 + 256.0).saturating_as::<u8>(), 255);

Disclaimer, I am the author of this crate (and I wrote it without knowing about az at the time).
It works similarly by using a marker trait to implement the behavior of casting between elements, but only works between integers.

use saturating_cast::SaturatingCast;
let x: i32 = 1024;
let y: u8 = 10;
let z = x.saturating_cast::<u8>() - y;
assert_eq!(245, z);

Metadata

Metadata

Assignees

No one assigned

    Labels

    ACP-acceptedAPI Change Proposal is accepted (seconded with no objections)T-libs-apiapi-change-proposalA proposal to add or alter unstable APIs in the standard libraries

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions