정의되지 않은 동작

정의되지 않은 동작 (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) 기록을 더 볼 수 있어요.