RangeFrom 반복자의 오버플로 동작, 일관성 문제, 그리고 가능한 대안을 살펴봅니다.
2025년 12월, 이 글보다 약 8개월 전에 James Munns는 RangeFrom이 끝에 도달하면 그냥 순환한다는 내용의 글을 작성했습니다. 저는 clippy 린트로 시작했지만, 결국 이 문제는 제 생각이 꽤 자주 흘러가곤 하는 주제 중 하나가 되었습니다. 이로 인해 저는 이에 관해 상당히 많은 의견과 생각을 갖게 되었습니다. 이 글에서 그것들을 세상과 공유하고 싶습니다.
이 글을 읽기 위한 엄격한 전제 조건은 아니지만, 이 연재 글의 첫 번째 파트는 몇 가지 역사적 배경을 제공합니다: RangeFrom, 파트 1..: 역사와 배경.
RangeFrom 반복자에 무엇을 기대하시겠습니까?사람들이 RangeFrom 반복자에 기대할 것이라고 생각하는 몇 가지를 나열해 보겠습니다.
let mut iter = (n..)라는 반복자가 있다고 해 봅시다. 이런 반복자에 제가 기대하는 속성은 다음과 같습니다.
n..은 최댓값까지, 그리고 최댓값을 포함해 모든 값을 산출합니다.overflow-checks가 꺼져 있을 때 오버플로로 패닉하지 않습니다.다음 부분에서 이들 중 어느 것도 맞지 않음을 보여드리겠습니다.
n..은 최댓값까지, 그리고 최댓값을 포함해 모든 값을 산출합니다.내부 카운터가 값을 산출하기 전에 증가하는 구현 세부 사항 때문에, overflow-checks를 활성화하면 반복자는 마지막 값(u8::MAX)을 산출하기 전에 오버플로합니다. 따라서 마지막 값은 오버플로 직전의 끝에서 두 번째 값이 됩니다.
for i in 253u8.. {
println!("{i}");
}
이 코드는 253, 254를 출력한 뒤 패닉합니다. overflow-checks가 활성화되지 않았다면 다행히 255도 출력합니다.
새 범위 타입에서 이것이 수정되었다는 점은 언급할 만합니다.
for i in std::range::RangeFrom::from(253u8..).into_iter() {
println!("{i}");
}
이 코드는 253, 254, 255를 출력하고, overflow-checks가 활성화되어 있으면 그다음 패닉합니다. 하지만 overflow-checks를 비활성화하면 현재 RangeFrom 타입과 동일하게 동작합니다. 즉, 무한 루프를 실행하게 되며, 이는 다음 항목으로 이어집니다.
이는 아마도 제가 현재 설계에 대해 갖는 주된 문제로 이어집니다. 저는 다음의 도달 불가능 문장이 실제로 도달 불가능하리라 기대합니다.
let range = 128u8..;
let iter = range.clone();
for i in iter {
if !range.contains(&i) {
unreachable!("Outside of range");
}
}
이 문장은 모든 정수 타입(u* 및 i*)[^2]에 대해 RangeFrom[^1] 반복자에서는 도달 가능합니다. 제게 이는 거의 말이 되지 않습니다. 범위의 핵심 개념, 특히 개념적으로 와 같은 것이라는 생각을 깨뜨리는 듯하기 때문입니다. 이는 오버플로를 방지하도록 확실히 보호하지 않는 한, 값을 위한 가드로 RangeFrom 반복자를 사용할 때 주의해야 함을 의미합니다. 이를 수행하는 한 가지 방법은 n..={Integer}::MAX를 사용하는 것이지만, 제게 이것은 개념적으로 가장 먼저 선택할 범위 타입이 아닙니다.
overflow-checks가 꺼져 있을 때 오버플로로 패닉하지 않습니다이 방식으로 동작하는 것은 원시 정수 타입뿐입니다. Step 트레이트를 구현하는 나머지 타입은 이와 다르게 동작합니다. char나 std::ascii::Char처럼 일부 값을 만드는 것이 정의되지 않은 동작인, 모든 비트 패턴이 잘 정의되어 있지 않은 타입에서는 이것이 합리적이라고 주장할 수 있습니다. 하지만 기반 정수 타입으로부터 완전한 매핑을 모두 갖는 Ipv4Addr와 Ipv6Addr를 보면, 이 둘 모두 오버플로 시 항상 패닉합니다.
// Always panics with
// library/core/src/iter/range.rs:118:45:
// overflow in `Step::forward`
for i in Ipv4Addr::new(255, 255, 255, 250).. {
println!("{i}");
}
제게 이것은 잘못된 동작으로 보입니다.
이는 Step 트레이트의 구현 참고 사항을 대체로 직접 따르는 것이라는 점은 언급할 만합니다.
이것이 Self가 지원하는 값의 범위를 오버플로한다면, 이 함수는 패닉하거나, 순환하거나, 포화할 수 있습니다. 권장 동작은 디버그 단언이 활성화된 경우 패닉하고, 그렇지 않으면 순환하거나 포화하는 것입니다.
안전하지 않은 코드는 오버플로 이후 동작의 정확성에 의존해서는 안 됩니다.
표준 라이브러리에는 Ipv4Addr처럼 권장 동작을 따르지 않는 여러 타입이 있습니다.
이에 관한 작은 부연으로, 어떤 구현도 debug-assertions를 사용하지 않고 대신 overflow-checks에 따라 동작을 바꾼다는 점이 있습니다. 다만 debug-assertions보다 overflow-checks에 의존하는 편이 더 타당하므로, 이는 다른 무엇보다 문서화 문제일 가능성이 있습니다.
이전 블록의 인용문을 읽었다면 «/포화/»라는 단어를 발견하고 어떤 타입이 그렇게 하는지 궁금했을 수 있습니다. 지금까지는 허용되지 않는 비트 패턴이 없는 타입만 살펴봤습니다. 그렇다면 그런 비트 패턴이 있는 타입에서는 어떻게 될까요? 표준 라이브러리는 두 가지 다른 방식으로 처리하므로 경우에 따라 다릅니다. char와 std::ascii::Char 같은 타입은 허용된 비트 패턴의 끝에 도달하면 항상 패닉합니다. 반면 포화하는 NonZero<u*>도 있습니다.
for i in NonZero::new(250u8).unwrap().. {
println!("{i}");
}
이 코드를 overflow-checks = true로 실행하면 마지막 값이 254인 상태에서 패닉합니다[^3].
이 코드를 overflow-checks = false로 실행하면 다음을 출력합니다.
250
251
252
253
254
255
255
255
255
255
255
...
그러고는 영원히 계속됩니다. 즉, 값이 그대로 유지되므로 항상 단조 증가하지는 않습니다. 표준 라이브러리의 다른 어디에서도 나타나지 않기 때문에 처음 보면 꽤 혼란스러울 수 있으며, 이는 다음 항목으로 이어집니다.
마지막으로 강조하고 싶은 점은 표준 라이브러리의 동작 방식이 조금 일관되지 않다는 것입니다. 서로 다른 타입이 7개 있습니다(부호 있는 정수와 부호 없는 정수는 각각 하나의 타입으로 셌습니다). 이들은 각각 3가지 서로 다른 방식 중 하나로 동작합니다.
표준 라이브러리의 여러 타입에 대한 Step 구현의 오버플로 동작[^4].
| 타입 | 디버그 | 릴리스 |
|---|---|---|
AciiChar | panic! | panic! |
char | panic! | panic! |
i* | panic! | T::MIN으로 오버플로 |
u* | panic! | T::MIN으로 오버플로 |
Ipv4Addr | panic! | panic! |
Ipv6Addr | panic! | panic! |
NonZero<u*> | panic! | 포화**!** |
이것은 적어도 여러 타입에 문서화되어야 할 사항입니다. 완전히 명확하지 않기 때문입니다. 현재는 나이틀리 전용 Step 타입에 일부 문서가 있지만, 각 타입별 문서는 없습니다.
현재 의미론을 지지하며 들었던 주장들을 살펴보겠습니다.
새로운 주장을 듣게 되면 이 절을 갱신할 수도 있습니다.
libs-api 팀이 이를 논의했을 때, 타당한 이유로 본 것 중 하나는 zip과 함께 사용하는 것이었습니다. 예를 들어 iter.zip(1..) 같은 것은 Step을 구현하는 임의의 타입을 사용할 수 있는 Iterator::enumerate 버전을 제공합니다. 그렇게 할 수 있는 것은 좋은 생각이라는 데 동의하지만, 더 나은 방식을 만들 수 있어야 한다고 생각합니다.
제 제안은 Iterator에 enumerate_with[^5] 같은 이름의 추가 메서드를 넣는 것입니다. 그러면 iter.enumerate_with(250u8)처럼 작성할 수 있고 스텝 구현을 사용하게 됩니다. 여러 단계씩 이동하는 방법도 추가할 수 있겠지만, 이를 깔끔하게 동작시키는 방법은 찾지 못했습니다[^6].
간단한 개념 증명을 구현했습니다.
pub struct EnumerateWith<T, I, const C: usize = 1> {
iter: I,
counter: T,
}
impl<T, I, const C: usize> Iterator for EnumerateWith<T, I, C>
where
T: Step,
I: Iterator,
{
type Item = (T, <I as Iterator>::Item);
fn next(&mut self) -> Option<(T, <I as Iterator>::Item)> {
let a = self.iter.next()?;
let i = Step::forward(self.counter.clone(), C);
self.counter = i.clone();
Some((i, a))
}
}
pub trait EnumerateWithExt: Iterator {
fn enumerate_with<T: Step>(self, start: T) -> EnumerateWith<T, Self>
where Self: Sized
{
EnumerateWith { iter: self, counter: start }
}
}
impl<T> EnumerateWithExt for T where T: Iterator + ?Sized {}
이 방식이라면 Step 타입과 함께 Zip을 사용할 때의 문제를 문서화할 장소가 생깁니다. 현재는 Step 트레이트 자체에만 문서화되어 있습니다. 이것은 재작성을 자동으로 수행하도록 Clippy 린트나 유사한 기능으로 추가할 수도 있을 것입니다.
이것은 어떤 면에서는 동의할 수 있는 주장입니다. 이런 의미론을 변경하기 전에 좋은 이유가 있어야 합니다. 분명 어딘가에는 이 의미론을 사용하는 사람이 있기 때문입니다. 이를 의존하는 사람이 있더라도 기본 debug 프로필에서는 오버플로가 패닉을 일으키므로 일반적인 테스트에서 드러나지 않을 가능성이 높아, 크레이터 실행 같은 방식으로도 찾기 어려울 것입니다.
따라서 이것이 파손을 일으키는 지점을 찾기는 어려울 수 있습니다.
하지만 같은 이유로 심각한 파손을 일으키지 않을 것이라고 주장할 수도 있습니다. 대부분의 경우 debug 프로필에서의 패닉은 작성자가 코드를 바꾸는 것을 고려할 만큼 충분히 나쁠 것이기 때문입니다.
개인적으로 RangeFrom 반복자는 경계가 있는 타입의 끝에 도달하면 그냥 None을 반환해야 한다고 생각합니다. 그렇게 하면 경계가 있는 타입의 구현에 더 많은 제어권을 줄 수 있습니다. 현재 RangeFrom 반복자는 항상 새 값을 반환해야 하는 Step::forward 메서드를 사용합니다. 이를 Step::checked_forward 사용으로 바꾸면 None을 반환할 수 있습니다. 그렇게 한다면 현재 타입과 같은 의미론을 갖도록 std::num::Wrapping에 특수 구현을 제공할 수 있습니다. 또한 제한 없이 커질 수 있는 메모리 기반 큰 정수에 대해서도 여전히 Step을 구현할 수 있습니다.
의미론이 실제로 명확하지 않으므로, 이것이 언어에서 위험한 함정을 하나 제거할 것이라고 믿습니다. 최근 새 Range* 타입으로 이를 처리할 기회가 있었지만, 타입들이 안정화되었으므로 반복자를 변경할 시기는 아마 지나갔을 것입니다.
이곳의 첫 번째 제대로 된 의견 글이므로, 인터넷 포럼이든 소개 페이지에 나열된 제 연락 수단이든 이를 통해 이 문제에 관한 모든 분의 생각을 듣게 된다면 매우 기쁠 것입니다.
이 글이 여러분을 설득하지 못했더라도 적어도 생각할 거리를 제공하기를 바랍니다.
이 글에 대한 피드백에 따라 현재 ACP(libs-team#304)를 되살리거나 새 ACP를 만들 수도 있습니다. 동작 변경에 더 많은 사람이 동의한다면 말입니다.
지난 몇 달 동안 Rust에 관해 이야기할 때 이것이 제가 즐겨 꺼내는 주제였기에, 오랜 시간 저와 이 문제를 이야기한 모든 분께 다시 사과드리고 싶습니다. 또한 제 말을 들어 주고 최종 글에 대한 피드백을 위해 이 글을 읽어 준
에게도 다시 감사드립니다.
읽어 주셔서 감사합니다.
u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize↩enumerate_in과 enumestep이 있습니다↩fn enumerate_with<T: Step, const C: usize = 1>(self, start: T)는 동작하지 않습니다 ↩