C++26에서 미초기화 읽기를 ‘오류 동작’으로 규정하고, 컴파일러가 이를 진단하도록 권고하는 변화와 [[indeterminate]] 어트리뷰트의 용법을 예제와 함께 설명합니다.
요즈음 C++ 콘퍼런스에서 임의의 발표를 골라 들어 보면, 발표자가 안전성(safety)을 최소한 몇 번은 언급할 확률이 높습니다. 아마 그게 괜찮을 겁니다. 위원회와 커뮤니티는 C++의 안전성과 평판을 모두 개선하는 방법을 생각해야 합니다.
이 영역에서 무슨 일이 벌어지는지 따라가고 있다면, 안전성에 대한 시각이 사람마다 다르다는 것을 아실 겁니다. 거의 모든 이가 안전성을 중요하게 생각하지만, 문제를 푸는 방식은 제각각입니다.
문제의 큰 원천은 정의되지 않은 동작(UB)의 특정 발현입니다. 이는 소프트웨어의 안전성과 안정성 모두에 영향을 줍니다. 몇 년 전, 10배 성장을 지원해야 하는 몇몇 서비스를 작업하던 때를 기억합니다. 그때 중요한 포인트 중 하나가 가능한 한 정의되지 않은 동작을 제거하는 것이었습니다. 특히 우리에게 중요한 점은 자주 서비스를 크래시로 이끄는 미초기화 변수들을 제거하는 것이었습니다.
Thomas Köppe의 P2795R5 덕분에, 미초기화 읽기(uninitialized read)는 C++26부터 더 이상 정의되지 않은 동작이 아닙니다. 대신, “오류 동작(erroneous behaviour)”이라는 새로운 동작을 갖게 됩니다.
오류 동작의 큰 장점은 기존 코드를 재컴파일하는 것만으로도 작동한다는 점입니다. 어디에서 변수를 초기화하지 않았는지 진단해 줍니다. 코드 전체를 체계적으로 훑으며, 이를테면 모든 것을 auto로 선언해 모든 변수가 초기화된 값을 갖도록 보장할 필요가 없습니다. 어차피 그렇게 하지는 않겠지만요.
그런데 C++ Reference에서는 정의되지 않은 동작의 페이지에도 올라 있는 이 새로운 동작이 무엇일까요? 이는 잘 정의되어 있지만 올바르지 않은 동작으로, 컴파일러가 이를 진단하도록 권고됩니다. 권고만으로 충분할까요?! 안전성에 대한 관심이 커지고 있는 만큼, 오류 동작을 진단하지 않는 구현은 곧 경쟁에서 밀려날 것이라고 안심하셔도 됩니다.
일부 컴파일러는 오늘날(아직은 UB에 속하는) 미초기화 읽기를 이미 식별할 수 있습니다. 예를 들어, clang과 gcc는 -ftrivial-auto-var-init=zero로 자동 저장 기간(automatic storage duration)을 가진 변수에 대해 기본 초기화를 이미 제공합니다. 이는 이러한 변수를 식별하는 기법이 이미 존재한다는 뜻입니다. 이 접근이 실용적이지 않은 유일한 이유는, 여러분이 초기화를 놓친 변수가 무엇인지 알 수 없다는 점입니다.
기본 초기화 대신, 오류 동작에서는 미초기화 객체가 구현에 따라 달라지는 값으로 초기화됩니다. 그러한 값을 읽는 것은 개념적 오류이며, 컴파일러가 이를 진단하도록 권고되고 장려됩니다. 그 진단은 경고, 런타임 오류 등으로 이뤄질 수 있습니다.
1
2
3
4
void foo() {
int d; // d에는 오류 값이 있다
bar(d); // 이는 오류 동작이다!
}
위 예제를 보면, 이상적으로는 int d;에서 이미 컴파일 타임 경고로 진단되어야 합니다. 그것을 무시한다면, 어느 순간 bar(d);가 프로그램 실행 중에 어떤 효과를 내게 되지만, 정의되지 않은 동작처럼 아무거나 일어나는 것이 아니라 잘 정의되어 있어야 합니다.
상수 표현식에서는 정의되지 않은 동작도, 오류 값도 존재할 수 없다는 점은 주목할 만합니다. 다시 말해,
constexpr는 이를 방지합니다.
어떤 객체를 무엇으로든 초기화하는 데에는 비용이 듭니다. 정말로 그 비용을 피하고 나중에 객체를 초기화하고 싶다면 어떻게 할까요? 진단 없이 여전히 그렇게 할 수 있을까요? 물론입니다! 다만 그 의도를 분명히 해야 합니다. 우연히 값을 미초기화 상태로 두어서는 안 되며, C++26의 새 어트리뷰트 [[indeterminiate]]로 표시해야 합니다.
1
2
3
4
void foo() {
int d [[indeterminate]]; // d는 불확정 값이다
bar(d); // 이는 정의되지 않은 동작이다!
}
예제에서 보듯이, d는 더 이상 오류 값이 아닙니다. 이제 그 값은 단지 불확정(indeterminate)입니다. 반면, 그 변수를 이후에도 초기화 없이 사용한다면, 그것은 정의되지 않은 동작입니다!
위에서는 자동 저장 기간을 가진 변수만 이야기했습니다. 그것만이 미초기화 변수를 만드는 유일한 길은 아닙니다. 어쩌면 그것이 주된 경로조차 아닐 겁니다. 동적 저장 기간을 생각해 보세요, 포인터를 생각해 보세요! 또한 어떤 멤버라도 미초기화 상태로 남아 있다면, 상위 객체의 값은 불확정이거나 오류로 간주됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
struct S {
S() {}
int num;
std::string text;
};
int main() {
[[indeterminate]] S s1; // 불확정 값
std::cout << s1.num << '\n' // s1.num이 불확정이므로 이는 UB
S s2;
std::cout << s2.num << '\n' // 이것도 여전히 UB. s2.num은 오류 값이다
}
변수뿐만 아니라 함수 매개변수도 [[indeterminate]]로 표시할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
struct S {
S() {}
int num;
std::string text;
};
void foo(S s1 [[indeterminate]], S s2)
{
bar(s1.num); // 정의되지 않은 동작
bar(s2.num); // 오류 동작
}
이 글을 쓰는 시점(2025년 1월)에는 오류 동작을 지원하는 컴파일러가 없습니다.
C++26은 미초기화 값을 읽는 경우에 대해, 잘 정의되어 있으나 올바르지 않은 동작을 제공하기 위해 오류 동작을 도입합니다. 곧 컴파일러는 미초기화 변수와 함수 매개변수의 모든 읽기 발생을 진단하도록 권고될 것입니다.
또한, 어떤 것이 의도적으로 특정 시점에 초기화되지 않았다면, 필요 없는 비용은 지불하지 않는다는 원칙에 따라 [[indeterminate]] 어트리뷰트로 표시할 수 있습니다.
이 새로운 동작은 C++의 안전성 측면에서 의미 있는 진일보입니다.
이 글이 마음에 드셨다면