정의되지 않은 동작
정의되지 않은 동작 (Undefined behavior)
프로그램이 언어의 특정 규칙을 위반하면 컴파일러가 멈추거나 경고를 주는 게 아니라, 프로그램 전체가 의미를 잃어버리는 상황이 있어요. 그게 정의되지 않은 동작(undefined behavior, UB)이에요. C++ 표준은 "이런 코드는 어떤 일이 일어나도 책임지지 않는다"고 선언해 두는데, 그래서 UB를 아는 게 컴파일러가 이상하게 최적화한 결과를 이해하는 데 필수예요.
출처: cppreference
본문
설명
C++ 표준은 다음 분류 중 하나에 속하지 않는 모든 C++ 프로그램의 관찰 가능한 동작을 정밀하게 정의해요.
-
ill-formed — 문법 오류나 진단 가능한 의미 오류가 있는 프로그램이에요.
- 표준을 준수하는 C++ 컴파일러는 이런 코드에 의미를 부여하는 언어 확장(가변 길이 배열처럼)을 정의하더라도 진단 메시지를 내도록 요구돼요.
- 표준 본문은
shall,shall not,ill-formed라는 표현으로 이런 요구 사항을 나타내요.
-
ill-formed, no diagnostic required — 일반적으로는 진단할 수 없을 수도 있는 의미 오류가 있는 프로그램이에요(예: ODR 위반이나 링크 시점에만 발견되는 오류).
- 이런 프로그램을 실행하면 동작이 정의되지 않아요.
-
implementation-defined behavior(구현 정의 동작) — 프로그램의 동작이 구현마다 달라지고, 표준을 준수하는 구현은 각 동작의 효과를 문서화해야 해요.
- 예를 들어
std::size_t의 타입, 바이트의 비트 수,std::bad_alloc::what의 텍스트 같은 것들이에요. - 구현 정의 동작의 부분집합으로 구현이 제공하는 로케일에 의존하는 로케일 특정 동작(locale-specific behavior)이 있어요.
- 예를 들어
-
unspecified behavior(명시되지 않은 동작) — 프로그램의 동작이 구현마다 달라지고, 표준을 준수하는 구현이 각 동작의 효과를 문서화할 의무는 없는 경우예요.
- 예를 들어 평가 순서, 동일한 문자열 리터럴이 서로 다른 객체인지 여부, 배열 할당 오버헤드의 크기 등이에요.
- 각 명시되지 않은 동작은 유효한 결과 집합 중 하나를 낳아요.
| erroneous behavior(오류 동작) — 구현이 진단하도록 권장되는 (잘못된) 동작이에요. |
|---|
| 오류 동작은 항상 잘못된 프로그램 코드의 결과예요. |
| 상수 표현식의 평가는 결코 오류 동작을 낳지 않아요. |
| 실행이 오류 동작으로 지정된 연산을 포함하면, 구현은 진단을 내도록 허용되고 권장되며, 그 연산 이후 명시되지 않은 시점에 실행을 종료하도록 허용돼요. |
| 구현은 프로그램 동작에 대한 구현 특정 가정 집합 아래에서 오류 동작에 도달할 수 있다고 판단하면 진단을 내릴 수 있는데, 이는 오탐(false positive)으로 이어질 수 있어요. |
오류 동작의 예 (since C++26):
#include <cassert>
#include <cstring>
void f()
{
int d1, d2; // d1, d2 have erroneous values
int e1 = d1; // erroneous behavior
int e2 = d1; // erroneous behavior
assert(e1 == e2); // holds
assert(e1 == d1); // holds, erroneous behavior
assert(e2 == d1); // holds, erroneous behavior
std::memcpy(&d2, &d1, sizeof(int)); // no erroneous behavior, but
// d2 has an erroneous value
assert(e1 == d2); // holds, erroneous behavior
assert(e2 == d2); // holds, erroneous behavior
}
unsigned char g(bool b)
{
unsigned char c; // c has erroneous value
unsigned char d = c; // no erroneous behavior, but d has an erroneous value
assert(c == d); // holds, both integral promotions have erroneous behavior
int e = d; // erroneous behavior
return b ? d : 0; // erroneous behavior if b is true
}
- undefined behavior(정의되지 않은 동작) — 프로그램의 동작에 아무런 제약이 없어요.
정의되지 않은 동작의 몇 가지 예로는 데이터 레이스, 배열 경계 밖의 메모리 접근, 부호 있는 정수 오버플로, 널 포인터 역참조, 하나의 표현식에서 <시퀀스 포인트 없이(until C++11)``비순서화된(sequenced되지 않은, since C++11)> 같은 스칼라를 두 번 이상 수정하는 것, 다른 타입의 포인터로 객체에 접근하는 것 등이 있어요. 구현은 정의되지 않은 동작을 진단할 의무는 없고(다만 많은 단순한 상황은 진단돼요), 컴파일된 프로그램은 어떤 의미 있는 일도 하지 않아도 돼요.
| runtime-undefined behavior(런타임 정의되지 않은 동작) — core constant expression으로서 표현식을 평가하는 동안을 제외하면 정의되지 않은 동작인 경우예요. | (since C++11) |
UB와 최적화
올바른 C++ 프로그램에는 정의되지 않은 동작이 없기 때문에, 실제로 UB가 있는 프로그램을 최적화와 함께 컴파일하면 컴파일러가 예상 밖의 결과를 낼 수 있어요.
예를 들어,
int foo(int x)
{
return x + 1 > x; // either true or UB due to signed overflow
}
다음과 같이 컴파일될 수 있어요(demo).
foo(int):
mov eax, 1
ret
int table[4] = {};
bool exists_in_table(int v)
{
// return true in one of the first 4 iterations or UB due to out-of-bounds access
for (int i = 0; i <= 4; i++)
if (table[i] == v)
return true;
return false;
}
다음과 같이 컴파일될 수 있어요(demo).
exists_in_table(int):
mov eax, 1
ret
std::size_t f(int x)
{
std::size_t a;
if (x) // either x nonzero or UB
a = 42;
return a;
}
다음과 같이 컴파일될 수 있어요(demo).
f(int):
mov eax, 42
ret
아래 출력은 오래된 버전의 gcc에서 관찰된 거예요.
#include <cstdio>
int main()
{
bool p; // uninitialized local variable
if (p) // UB access to uninitialized scalar
std::puts("p is true");
if (!p) // UB access to uninitialized scalar
std::puts("p is false");
}
가능한 출력:
p is true
p is false
int f()
{
bool b = true;
unsigned char* p = reinterpret_cast<unsigned char*>(&b);
*p = 10;
// reading from b is now UB
return b == 0;
}
다음과 같이 컴파일될 수 있어요(demo).
f():
mov eax, 11
ret
다음 예시들은 널 포인터 역참조의 결과를 읽는 상황을 보여줘요.
int foo(int* p)
{
int x = *p;
if (!p)
return x; // Either UB above or this branch is never taken
else
return 0;
}
int bar()
{
int* p = nullptr;
return *p; // Unconditional UB
}
다음과 같이 컴파일될 수 있어요(demo).
foo(int*):
xor eax, eax
ret
bar():
ret
#include <cstdlib>
#include <iostream>
int main()
{
int* p = (int*)std::malloc(sizeof(int));
int* q = (int*)std::realloc(p, sizeof(int));
*p = 1; // UB access to a pointer that was passed to realloc
*q = 2;
if (p == q) // UB access to a pointer that was passed to realloc
std::cout << *p << *q << '\n';
}
가능한 출력:
12
#include <iostream>
bool fermat()
{
const int max_value = 1000;
// Non-trivial infinite loop with no side effects is UB
for (int a = 1, b = 1, c = 1; true; )
{
if (((a * a * a) == ((b * b * b) + (c * c * c))))
return true; // disproved :()
a++;
if (a > max_value)
{
a = 1;
b++;
}
if (b > max_value)
{
b = 1;
c++;
}
if (c > max_value)
c = 1;
}
return false; // not disproved
}
int main()
{
std::cout << "Fermat's Last Theorem ";
fermat()
? std::cout << "has been disproved!\n"
: std::cout << "has not been disproved.\n";
}
가능한 출력:
Fermat's Last Theorem has been disproved!
진단 메시지와 함께하는 ill-formed
컴파일러는 ill-formed 프로그램에 의미를 부여하는 방식으로 언어를 확장하도록 허용돼요. 그런 경우 C++ 표준이 요구하는 유일한 것은 진단 메시지(컴파일러 경고)뿐이에요. 단, 프로그램이 "ill-formed no diagnostic required"인 경우는 예외예요.
예를 들어, --pedantic-errors로 언어 확장을 끄지 않으면 GCC는 C++ 표준에서 "오류"의 예로 제시된 다음 예시를 경고 하나만으로 컴파일해요(GCC Bugzilla #55783 참고).
#include <iostream>
// Example tweak, do not use constant
double a{1.0};
// C++23 standard, §9.4.5 List-initialization [dcl.init.list], Example #6:
struct S
{
// no initializer-list constructors
S(int, double, double); // #1
S(); // #2
// ...
};
S s1 = {1, 2, 3.0}; // OK, invoke #1
S s2{a, 2, 3}; // error: narrowing
S s3{}; // OK, invoke #2
// — end example]
S::S(int, double, double) {}
S::S() {}
int main()
{
std::cout << "All checks have passed.\n";
}
가능한 출력:
main.cpp:17:6: error: type 'double' cannot be narrowed to 'int' in initializer
list [-Wc++11-narrowing]
S s2{a, 2, 3}; // error: narrowing
^
main.cpp:17:6: note: insert an explicit cast to silence this issue
S s2{a, 2, 3}; // error: narrowing
^
static_cast<int>( )
1 error generated.
더 알아보기
[[assume(expression)]](C++23)은 주어진 지점에서 표현식이 항상true로 평가됨을 지정하는 속성 지정자예요.[[indeterminate]](C++26)은 객체가 초기화되지 않으면 결정되지 않은(indeterminate) 값을 가진다고 지정하는 속성 지정자예요.std::unreachable(C++23)은 도달할 수 없는 실행 지점을 표시하는 함수예요.- The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #1/3, #2/3, #3/3
- Understanding Integer Overflow in C/C++, Fun with NULL pointers part 1
- Undefined Behavior and Fermat's Last Theorem, C++ programmer's guide to undefined behavior
- cppreference의 정의되지 않은 동작 원문에서 결함 보고(defect report) 기록을 더 볼 수 있어요.