as-if 규칙

as-if 규칙 (The as-if rule)

컴파일러가 코드를 이렇게저렇게 바꿔도 되는 건가 싶을 때가 있어요. C++에는 그걸 허용해 주는 근거 규칙이 있는데, 바로 'as-if 규칙'이에요. 프로그램의 관찰 가능한 동작(observable behavior)만 바꾸지 않는다면 어떤 코드 변환이든 허용한다는 법칙이죠. 이 페이지에서 그 규칙이 정확히 무엇을 보장하고 어디까지 허용하는지 정리해 볼게요.

출처: cppreference

본문

프로그램의 관찰 가능한 동작을 바꾸지 않는 코드 변환은 무엇이든 허용돼요.

설명

프로그램의 관찰 가능한 동작에는 다음이 포함돼요.

  • 대화형 장치로 보내는 안내 텍스트는 프로그램이 입력을 기다리기 전에 표시돼야 해요.
  • ISO C 프래그마 #pragma STDC FENV_ACCESS가 지원되고 ON으로 설정돼 있다면, 부동소수점 환경(부동소수점 예외와 반올림 모드)의 변경은 부동소수점 산술 연산자와 함수 호출에서 '쓴 대로(as written)' 관찰되는 것이 보장돼요. 단, 캐스트와 대입을 제외한 어떤 부동소수점 표현식의 결과도 표현식의 타입과 다른 부동소수점 타입의 범위·정밀도를 가질 수 있어요(FLT_EVAL_METHOD 참고). 그럼에도 불구하고 어떤 부동소수점 표현식의 중간 결과도 무한 범위·정밀도로 계산될 수 있어요(#pragma STDC FP_CONTRACT가 OFF가 아닌 한).

주의

컴파일러는 (보통) 외부 라이브러리의 코드가 I/O나 volatile 접근을 하는지 분석할 수 없으므로, 서드파티 라이브러리 호출도 최적화의 영향을 받지 않아요. 하지만 표준 라이브러리 호출은 최적화 중에 다른 호출로 대체되거나, 제거되거나, 추가될 수 있어요. 정적으로 링크된 서드파티 라이브러리 코드는 링크 타임 최적화(LTO)의 대상이 될 수 있어요.

정의되지 않은 동작(undefined behavior)이 있는 프로그램은 최적화 설정이 달라지면 관찰 가능한 동작이 자주 바뀌어요. 예를 들어 부호 있는 정수 오버플로 검사가 그 오버플로의 결과에 의존한다면, if (n + 1 < n) abort(); 같은 코드는 일부 컴파일러에서 통째로 제거돼요. 부호 있는 오버플로는 undefined behavior이고, 옵티마이저는 그게 절대 일어나지 않는다고 가정해도 되기 때문에 그 검사는 불필요하다고 보는 거예요.

**복사 생략(copy elision)**은 as-if 규칙의 예외예요. 컴파일러는 관찰 가능한 부수 효과가 있어도 move·copy 생성자 호출과 그에 대응하는 임시 객체 소멸자 호출을 제거할 수 있어요.

부동소수점 예외의 개수와 순서는, 다음 부동소수점 연산이 관찰하는 상태가 최적화가 없었던 것과 같기만 하다면 최적화로 바뀔 수 있어요.

#pragma STDC FENV_ACCESS ON
for (i = 0; i < n; ++i)
    x + 1; // x + 1은 죽은 코드지만, FP 예외를 일으킬 수 있음
           // (옵티마이저가 그 반대를 증명할 수 없다면). 그런데 그것을 n번
           // 실행하면 같은 예외를 계속 일으킬 거예요. 그래서 이렇게 최적화 가능:
if (0 < n)
    x + 1;

예시

int& preinc(int& n) { return ++n; }
int add(int n, int m) { return n + m; }

// 상수 접기를 막기 위한 volatile 입력
volatile int input = 7;

// 결과를 보이는 부수 효과로 만들기 위한 volatile 출력
volatile int result;

int main()
{
    int n = input;
// 내장 연산자를 쓰면 undefined behavior를 일으킬 것
//  int m = ++n + ++n;
// 하지만 함수를 쓰면 함수가 겹치지 않은 것처럼(as-if) 코드가 실행됨을 보장
    int m = add(preinc(n), preinc(n));
    result = m;
}

출력:

# GCC 컴파일러가 만든 main() 함수의 전체 코드
# x86 (Intel) 플랫폼:
        movl    input(%rip), %eax   # eax = input
        leal    3(%rax,%rax), %eax  # eax = 3 + eax + eax
        movl    %eax, result(%rip)  # result = eax
        xorl    %eax, %eax          # eax = 0 (main()의 반환값)
        ret

# PowerPC (IBM) 플랫폼:
        lwz 9,LC..1(2)
        li 3,0          # r3 = 0 (main()의 반환값)
        lwz 11,0(9)     # r11 = input;
        slwi 11,11,1    # r11 = r11 << 1;
        addi 0,11,3     # r0 = r11 + 3;
        stw 0,4(9)      # result = r0;
        blr

# Sparc (Sun) 플랫폼:
        sethi   %hi(result), %g2
        sethi   %hi(input), %g1
        mov     0, %o0                 # o0 = 0 (main()의 반환값)
        ld      [%g1+%lo(input)], %g1  # g1 = input
        add     %g1, %g1, %g1          # g1 = g1 + g1
        add     %g1, 3, %g1            # g1 = 3 + g1
        st      %g1, [%g2+%lo(result)] # result = g1
        jmp     %o7+8
        nop

# 모든 경우에 preinc()의 부수 효과가 제거되고, main() 전체가
# result = 2 * input + 3; 과 동등해졌음

이 예시가 보여주는 핵심은, 소스 코드에서는 add(preinc(n), preinc(n))이라서 n이 두 번 증가해야 할 것처럼 보이지만, as-if 규칙 덕분에 컴파일러가 관찰 가능한 동작(결국 result)만 같게 유지하면 n을 두 번 증가시키는 부수 효과를 얼마든지 재배치·제거할 수 있다는 거예요.

더 알아보기

  • as-if 규칙의 대표적인 예외인 복사 생략(copy elision)을 함께 보면 좋아요.
  • 정의되지 않은 동작(undefined behavior)이 최적화에 어떤 자유를 주는지도 이 규칙과 맞물려 이해할 수 있어요.
  • cppreference의 as-if 규칙 원문을 참고할 수 있어요.