Unsigned Sizes: 5년간의 실수
Source: Hacker News
“C3를 따르지 않는 독자들을 위한 짧은 메모: C3는 C 전통을 잇는 시스템 언어입니다. 아래 내용은 C3에 해당하지만, 크기와 길이에 대한 타입을 선택해야 하는 모든 언어에 적용되는 트레이드‑오프입니다.”
왜 C3가 기본적으로 부호 있는 정수로 이동하고 있는가
C3는 기본적으로 부호 있는 정수로 이동하고 있지만, 왜 그렇게 하는 걸까요? 최소한 크기에는 부호 없는 정수가 더 정확하지 않을까요? 이에 대해 답해 보겠습니다.
Source: …
unsigned의 버그
초창기부터 C3는 unsigned 크기를 사용해 왔습니다. unsigned 타입의 이름은 시간이 지나면서 usize에서 usz( uptrdiff 타입과 통합된 후)로 바뀌었지만, 기본값으로서의 위치는 변함이 없었습니다.
하지만 unsigned에는 알려진 함정이 있는데, 가장 유명한 예는 다음과 같습니다:
for (uint x = 10; x >= 0; x--) // 무한 루프!
{
…
}
이 버그는 너무 흔해서 C3는 매크로 밖에서 unsigned 타입에 대해 x >= 0을 명시적으로 금지합니다.
또 다른 고전적인 C 버그는 다음과 같습니다:
uint a = 0;
int b = -1;
if (a > b) { … }
C에서는 두 피연산자가 모두 unsigned로 승격되어 b가 거대한 unsigned 값으로 변하고 비교가 실패합니다. 이러한 이유로 C3는 양쪽을 모두 변환하지 않고 피연산자 타입에 관계없이 안전한 안전한 unsigned/ signed 비교를 구현합니다.
물론 C는 unsigned와 signed 사이의 암시적 변환을 허용합니다. 이는 버그의 원인이 되지만, 몇 가지 안전 장치를 두면 대부분은 유지할 수 있다고 생각했습니다.
위의 버그들이 서로 무관한 특이 현상이라고 생각하기 쉽습니다: 절대 종료되지 않는 루프, 잘못된 비교, 꼭 맞춰야 하는 변환… 이 모든 것은 하나의 초기 결정, 즉 크기에 대해 unsigned를 기본값으로 삼은 것에서 비롯되었습니다. 이 글의 대부분은 바로 그 결정에 관한 이야기입니다.
중요한 질문
“signed/unsigned 변환을 명시적으로만 허용하면 안 될까?” 라고 물을 수 있습니다.
그 이유는 바로 unsigned 크기와 관련이 있습니다.
크기가 unsigned이면—C, C++, Rust, Zig, C3 모두 그렇듯—데이터 인덱싱과 관련된 모든 작업은 전부 unsigned이거나 캐스트가 필요합니다. C의 느슨한 의미론에서는 이 문제가 크게 부각되지 않지만, Rust에서는 크기를 다룰 때마다 캐스트를 앞뒤로 반복해야 했습니다.
캐스트에 대한 두 가지 접근법이 있습니다:
- 코드 전반에 자유롭게 뿌리기 – “명시적 변환이니 결과가 명확하다”는 생각으로.
- 캐스트 최소화 – 평범하지 않은 상황을 알리기 위해서만 사용: “여기엔 위험이 있다”.
첫 번째 방법은 정의하기 쉽지만, 사실상 경고를 무음화합니다. 예를 들어, 원래 u16을 u32로 캐스트하던 코드를 생각해 보세요. 나중에 변수 타입이 u64로 바뀌면, 캐스트는 값을 조용히 잘라냅니다. 캐스트가 “모든 경고를 무음화하는” 수단이 되는 겁니다.
“명시적 변환”이라는 주된 아이디어는 컴파일러가 요구하는 곳마다 기계적으로 캐스트를 삽입할 때도 약화됩니다.
반면 캐스트를 최소화하는 것은 더 까다롭습니다: 안전한 암시적 캐스트는 허용하고, 위험한 경우에는 명시적 캐스트를 요구하는 규칙이 필요합니다.
C3는 두 번째 접근법을 택했습니다—캐스트는 의미가 있어야 합니다. 그런데 왜 unsigned ↔ signed 변환을 전혀 차단하지 않았을까요? 위험하지 않을까요?
실제로, 덧셈·뺄셈·곱셈만 사용한다면, signed 정수가 2의 보수 체계일 경우 변환은 대부분 안전합니다. 변환은 자주 일어나야 하므로(기억하세요: unsigned 크기!), 이를 암시적으로 허용하는 것이 자연스러운 선택이었습니다.
가장 완벽한 계획도
C3는 2021년 이후 현재의 변환 의미론을 대부분 유지해 왔으며, 5년 동안 큰 문제 없이 잘 작동했습니다—하지만 (foo + a) % 2에 대한 순수한 질문이 그 가정을 뒤집어 놓았습니다.
문제점을 없애기 위해 C3는 int + uint 연산이 unsigned가 아니라 int 로 승격되도록 규칙을 바꾸었습니다. 이로 인해 많은 경우가 조용히 signed가 되었고, 대부분 상황에서 올바른 선택이었습니다.
하지만 foo가 INT_MAX보다 큰 경우 (foo + a) % 2를 생각해 보세요. 이제 이해할 수 없는 결과가 나오고, 올바른 답은 (foo + a) % 2U가 됩니다.
이는 고치기 어려워서가 아니라, 너무 놀라워서 받아들일 수 없었습니다. 거의 모든 다른 곳에서는 기본 변환이 signed인지 unsigned인지 신경 쓰지 않아도 되었지만, 여기서는 그 차이가 크게 부각되었습니다.
Source:
ed 또는 unsigned – 그냥 작동했다. 그런데 /와 %는? 여기서 해결책이 무너졌다. 다른 곳에서 “그냥 작동”했기 때문에 어떤 하위 표현식이 signed인지 unsigned인지 파악하기가 꽤 애매했다. 편리함이 사소한 문제를 큰 문제로 만들었다.
즉각적인 반응은 패치를 적용하는 것이었다: “unsigned / signed”와 “unsigned % signed”에 대해 오류를 발생시키는 것이었다. 하지만 더 많은 문제가 그림자 속에 숨어 있었다.
까다로운 래핑
링 버퍼를 작성한다면, 오프셋 계산이 올바르게 래핑되도록 어떻게 보장할까?
순진한 해결책은 다음과 같다:
index = (start + offset) % length;
이 코드는 offset이 양수일 때는 잘 동작한다. 음수 값은 어떨까? 흔히 쓰이는 간단한 해결책은 다음과 같다:
index = ((start + offset) % length + length) % length;
offset이 음수이므로 signed 숫자를 가정한다; 매우 큰 오프셋( signed overflow를 일으키는 경우)만 제외하면 이 방법은 동작한다.
이제 unsigned 크기로 시작했음을 기억하라. 모든 곳에 unsigned를 사용하면 코드는 다음과 같이 보인다:
index = ((start - offset_back) % length + length) % length;
이것은 완전히 잘못된 코드이며—발견하기도 어렵다. 가끔은 올바르게 래핑되지만 대부분은 그렇지 않다.
unsigned 연산에 맞는 올바른 코드는 다음과 비슷해야 한다:
index = (start + length - (offset_back % length)) % length;
unsigned ↔ signed 변환 규칙을 어떻게 적용하든, 컴파일러가 첫 번째 offset_back 예제가 unsigned에서는 깨졌다는 것을 알려줄 방법은 전혀 없다.
unsigned 크기
unsigned로 문제를 해결하기가 어려워 보이니, 어쩌면 잘못된 가정을 하고 있는 것일 수도 있다.
시간을 되돌아보면: C는 원래 signed 정수를 중심으로 설계되었으며, int 타입이 핵심이었다. sizeof의 타입이 unsigned size_t로 표준화되면서 모든 것이 바뀌었다.
그 단 하나의 변화가… (원문은 여기서 끝난다).
Unsigned arithmetic is a common thing in C code. Finding this new shiny thing, people started to use `unsigned` to encode “this value can’t be negative” and talked about how using `unsigned` helped since it allowed them to express larger sums.
그렇다고 해서 문제가 없다는 뜻은 아니다. 실제로 문제는 너무 심각해서 90년대에 Java는 설계 단계에서 unsigned 타입을 완전히 없애기로 결정했다. Java의 반응은 다소 과했을지 모르지만, unsigned와 관련된 흔한 버그들을 대폭 줄이는 목표는 달성했다.
Go도 생각해 볼 필요가 있다: 이는 저수준 언어이며, C++의 문제에 대한 반작용으로, unsigned 크기의 비용을 정확히 알고 있던 사람들에 의해 설계되었고, 그들은 signed 크기를 선택했다.
제한된 정수에서는 경계에 가까워질 때 문제가 발생한다. 32‑bit signed int는 대략 ±2 billion 정도이고, unsigned 32‑bit 정수는 0 … ≈4 billion이다. unsigned의 “안전하지 않은” 경계는 signed 정수보다 훨씬 가깝다—경쟁의 여지가 없다.
이것이 바로 %와 같은 경우에 문제가 발생하는 정확한 이유다.
그렇다면 범위는 어떨까? 범위가 두 배가 된다는 것은 사실이지만, INT_MAX를 초과하는 범위의 코드는 의외로 버그가 많다. 다음과 같은 코드를 생각해 보라.
(2U * index) / 2U
이 범위에서는 꽤 놀라운 결과가 나타난다. 더 심각한 점은: signed 값에서 오버플로는 일반적으로 잘못된 음수 값을 만든다—하지만 unsigned 오버플로는 종종 그럴듯하지만 잘못된 값을 만든다. 현대 64‑bit 머신에서는 전체 signed 64‑bit 정수를 사용할 수 있기 전에 메모리가 부족해진다.
설계상 올바른 범위에 있는 것이 가치 있지 않은가?
답은 아니다인 듯 보인다. 검증 프레임워크 작업을 보면, unsigned는 단지 모듈로 연산과 실제 범위만을 인코딩한다. unsigned 오버플로를 오류로 만들 수는 있다(이는 Rust가 실제로 하는 일)지만, 이는 unsigned 연산의 유용한 특성을 없앤다:
(a + b) - c == a + (b - c) // unsigned 연산이 래핑될 때는 참
오버플로가 n… (텍스트가 여기서 끊김)
Source: …
ot allowed, the equality no longer holds – a trap in itself.
So we have unsigned quite frequently used, more or less by historical accident. It’s error‑prone and silently hides errors. Maybe the solution isn’t trying to make it more ergonomic?
허용되지 않으면, 등식이 더 이상 성립하지 않는다 – 자체적인 함정이다.
그래서 우리는 unsigned를 역사적인 우연에 의해 꽤 자주 사용하게 된다. 이것은 오류가 발생하기 쉽고 조용히 오류를 숨긴다. 어쩌면 해결책은 그것을 더 인간공학적으로 만들려는 것이 아닐지도 모른다.
Signed first
예상했듯이 C3는 타입과 길이에 대해 부호가 있는(signed) 크기를 채택했습니다. 이제 unsigned가 드물어졌기 때문에 unsigned와 signed 사이의 암시적 변환이 필요하지 않습니다. unsigned와 signed 간의 비교? – 역시 사라졌습니다.
이 변경 작업을 진행하면서 uint와 ulong 사용을 정리하기 시작했고, 의심스럽거나 명백히 잘못된 코드들을 발견했습니다. 또한 전체적으로 int와 부호가 있는 크기만 사용하게 되면서 코드가 훨씬 깔끔해졌습니다. 여기서 나는 unsigned 사용의 비용을 내면화하고 있었다는 것을 깨달았습니다. C나 C++에서 어느 정도 작업해 보면 unsigned 때문에 발생할 수 있는 문제들을 찾는 습관이 생기고, unsigned와 signed 변수 모두에 확실히 동작하는 덜 직관적인 패턴을 사용하게 됩니다.
이 변화를 적용하는 데 이렇게 오래 걸린 것이 조금 부끄럽고, 그만큼 습관이 얼마나 깊이 자리 잡았는지를 보여줍니다. 나는 단순히 unsigned 크기가 정답이라고 가정했고, 문제는 ergonomics를 개선하고 가능한 많은 함정을 없애는 것이라고 생각했습니다. 이는 Go와 Java가 부호가 있는 크기를 사용해 좋은 예를 보여주고 있음에도 불구하고였습니다.
변경을 결정한 뒤에도 unsigned를 signed로 변환하는 것이 처음엔 어색하고 잘못된 느낌이 들었습니다. 마치 금지된 일을 하는 듯했죠 – 그만큼 내가 얼마나 깊이 빠져 있었는지 보여줍니다. 하지만 각 변경이 코드를 더 이해하기 쉽고, 더 정확하게 만든다는 것을 확인하면서 그 증거를 부정할 수 없었습니다.
C3 변경 사항에 대한 몇 가지 메모
- 이 변경은 구현되기 전에 C3 Discord에서 논의되었으며,
isz타입(ssize_t와 대략 대응)이 기본 크기 타입이 된다는 의미에서 애정 어린 이름 “iszmageddon” 으로 불렸다. - 서명된 크기를 보다 명확히 강조하기 위해 이름을
sz로 바꾸었고, 버전 0.8.0에서는 비대칭 쌍sz/usz를 제공한다. 이렇게 하면 어느 것이 선호되는지 기억하기 쉽다. 따라서 변경 이름도 “szmageddon” 으로 바뀌었다. - 원래는 부호 있는 ↔ 부호 없는 간의 암시적 변환이 주로 유지되었지만, 이후 완전히 제거되었다.
이 기사를 Hacker News에서 토론하세요.