RFA-363 · Case file with fixtures · Case 335 of 694 · Runtime evidence
i32::unbounded_shl Returns Zero When the Count Reaches 32
unbounded_shl models an unlimited-width shift whose bits all leave the fixed integer once the count reaches its width. This differs deliberately from checked, wrapping, overflowing, and strict shift policies.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024, unbounded_shl stable since Rust 1.87
- Targets
- all Rust targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- unbounded_shl preserves an excessive shift distance, so every bit leaves the fixed-width result once the count reaches i32::BITS.
- First discriminating check
- Build a boundary table at 31, 32, and 33 and compare unbounded, checked, wrapping, and strict policies before choosing one.
I learned to be suspicious of a phrase such as “safe large shift” because it does not say what large counts mean. i32::unbounded_shl treats a count of 32 as shifting every bit out, so 42_i32.unbounded_shl(32) is zero.
The failing program expects the count to wrap around and leave 42 unchanged. That behavior belongs to wrapping_shl, not unbounded_shl.
Unbounded describes the conceptual shift space
For a fixed 32-bit value, shifting left by one moves every bit one position and discards anything beyond the high edge. If I continue for 32 positions, no original bit remains inside the value. The natural result is zero.
unbounded_shl(rhs) follows this model without first restricting rhs to the integer width. When rhs >= i32::BITS, it returns zero. It is stable since Rust 1.87, so code supporting an older minimum Rust version must use another expression or compatibility layer.
The word does not mean that the result grows into an arbitrary-precision integer. The result type remains i32.
wrapping_shl answers a different question
wrapping_shl masks the shift count to the type width. On i32, count 32 behaves like zero, count 33 like one, and so on.
This resembles hardware instructions and is useful in bit mixing or algorithms where the modulo count is intentional. It does not model “move bits 32 places in an unbounded bit string and then keep the low 32.”
The same input can therefore produce 42 from wrapping policy and 0 from unbounded policy. Neither is a universal overflow answer.
checked_shl preserves invalid distance
checked_shl returns None when the count is at least the bit width. I use it when an out-of-range count means malformed input or a failed precondition.
For counts below 32, checked_shl only checks the distance. It can still discard significant high bits while returning Some. This is an easy second confusion: “checked shift” does not mean lossless multiplication by a power of two.
If value-bit loss matters, I need a wider type, a reverse check, or a checked multiplication policy that matches the domain.
strict_shl makes the precondition loud
Newer Rust also provides strict_shl, which panics for an excessive count regardless of ordinary overflow-check settings. This fits cases where the count is a programming invariant and continuing would hide a defect.
I do not use a panic-based policy for untrusted protocol values unless a higher boundary has already validated them. A returned error is normally easier to operate than terminating a request path.
Unchecked shifts add an unsafe precondition and are not a faster spelling to select casually. Violating their count requirement can be undefined behavior.
Signed left shift still operates on bits
The receiver is i32, but left shifting does not preserve mathematical sign or magnitude when high bits leave the representation. Counts below the width can produce negative, zero, or otherwise truncated results.
unbounded_shl only defines the excessive-count case cleanly. It does not convert the operation into arbitrary-precision signed arithmetic.
When I mean scaling, I usually use checked or saturating multiplication and describe the overflow policy in numeric terms. When I mean a bit field, shift APIs make the representation intent clearer.
Chaining shifts is not always one combined shift
The official examples expose a subtle consequence. Applying a small shift and then a larger one can discard bits during the first step before the second. In fixed-width arithmetic, intermediate truncation matters.
I avoid algebraically combining or splitting shifts unless the chosen semantics prove the transformation. For wrapping counts, modulo arithmetic affects the distance; for unbounded counts, reaching the width zeros everything.
Compiler optimization may rewrite equivalent expressions, but source-level equivalence must come from the API contract, not a guess about a CPU instruction.
Boundary tables are better than one happy example
The repaired program evaluates counts 31, 32, and 33. For 42, the count-31 result is already zero because its set bits leave the range. Counts 32 and 33 are guaranteed zero under unbounded semantics.
It then shows that wrapping_shl(32) returns 42, while checked_shl(32) returns None. These three outputs put policy differences beside each other.
My real tests include zero, width minus one, width, width plus one, a very large u32, positive and negative receivers, and values with edge bits set. The boundary is where method names stop looking interchangeable.
The core principle is to choose overflow policy before the operation
Rust provides several integer methods because applications need different answers: reject, panic, wrap the count, report overflow, or shift all bits away. Hiding the policy behind a generic helper makes review harder.
unbounded_shl says that a large distance remains large. Once the distance reaches the fixed width, no input bit survives, and zero is the deliberate result.