C++ 증감 연산자(i++ vs ++i)의 동작 원리와 어셈블리 분석

C++ 개발자들 사이에서 전위 증감(++i)과 후위 증감(i++)의 차이에 대해 흔히 알려진 몇 가지 통념이 있습니다. 가장 대표적인 것은 다음과 같습니다.

  1. ++ii++보다 성능상 우위에 있다.
  2. i++는 내부적으로 임시 객체를 생성한 뒤 증가시키고 기존 값을 반환하는 반면, ++i는 참조를 반환하므로 효율적이다.
  3. sizeof 연산자의 처리 방식에 대한 오해.

이러한 주장들이 실제 어셈블리 수준에서 어떻게 구현되는지 Win10 x86 환경을 기준으로 분석해 보겠습니다.

1. 단독 증감 연산 시의 비교

단순히 변수 값을 1 증가시키는 코드에서 두 연산자의 차이를 확인해 봅니다.

int val = 0;
val = sizeof(val);
val++;
++val;

위 코드의 어셈블리 출력 결과는 다음과 같습니다.

// int val = 0;
mov dword ptr [val], 0         

// val = sizeof(val);
mov dword ptr [val], 4         

// val++;
mov eax, dword ptr [val]      
add eax, 1                    
mov dword ptr [val], eax       

// ++val;
mov eax, dword ptr [val]      
add eax, 1                   
mov dword ptr [val], eax

여기서 주목할 점은 sizeof의 처리입니다. 어셈블리 코드를 보면 함수 호출(call) 없이 즉시 4라는 상수가 대입됩니다. 이는 sizeof가 컴파일 타임에 결정되는 연산자임을 보여줍니다.

또한, 단독으로 사용된 val++++val의 어셈블리 명령어는 완전히 동일합니다. 따라서 기본 자료형을 단독으로 증감시킬 때는 성능 차이가 전혀 없음을 알 수 있습니다.

2. 수식에 참여하는 경우의 분석

결과값을 다른 변수에 할당하는 상황을 가정해 보겠습니다.

int data = 0;
data = sizeof(data);
int result_a = data++;
int result_b = ++data;

이 경우의 어셈블리는 다음과 같이 차이가 발생합니다.

// int result_a = data++;
mov eax, dword ptr [data]      // 현재 data 값을 eax에 복사
mov dword ptr [result_a], eax  // 복사된 값을 result_a에 할당
mov ecx, dword ptr [data]      
add ecx, 1                    
mov dword ptr [data], ecx      // data 값을 1 증가

// int result_b = ++data;
mov eax, dword ptr [data]      
add eax, 1                    
mov dword ptr [data], eax      // data 값을 먼저 1 증가
mov ecx, dword ptr [data]      
mov dword ptr [result_b], ecx  // 증가된 값을 result_b에 할당

사용되는 레지스터의 순서만 다를 뿐, 실행되는 명령어의 총량은 비슷합니다. 후위 연산(i++)이 임시 변수를 스택에 생성한다는 통념과 달리, 컴파일러는 레지스터를 활용하여 효율적으로 값을 전달합니다.

3. L-value와 R-value의 특성

C++에서 ++i = 5;는 컴파일되지만 i++ = 5;는 오류를 발생시킵니다. 이는 전위 연산이 증가된 변수 자체(L-value)를 반환하는 반면, 후위 연산은 증가 전의 값(R-value)을 반환하기 때문입니다.

만약 후위 연산이 대입 연산이 가능하다고 가정한다면, 어셈블리 상에서는 특정 레지스터에 값을 넣은 뒤 그 레지스터가 연산되는 식의 무의미한 동작이 발생하게 될 것입니다. 컴파일러는 이러한 논리적 모순과 무의미한 연산을 방지하기 위해 이를 문법적으로 차단합니다.

4. 클래스 멤버 및 연산자 오버로딩

사용자 정의 클래스에서 연산자를 오버로딩하는 경우를 살펴봅니다.

class Counter {
    int count = 10;
public:
    void operator++();    // 전위
    void operator++(int); // 후위
};

void Counter::operator++() {
    count++;
}

void Counter::operator++(int) {
    ++count;
}

이 코드에 대한 어셈블리는 다음과 같습니다.

// operator++() 내의 count++;
mov eax, dword ptr [this]  
mov ecx, dword ptr [eax]  
add ecx, 1  
mov edx, dword ptr [this]  
mov dword ptr [edx], ecx 

// operator++(int) 내의 ++count;
mov eax, dword ptr [this]  
mov ecx, dword ptr [eax]  
add ecx, 1  
mov edx, dword ptr [this]  
mov dword ptr [edx], ecx 

사용자 정의 클래스에서도 내부적으로 멤버 변수를 다룰 때 추가적인 임시 객체 생성 없이 레지스터를 통해 직접 연산이 이루어짐을 확인할 수 있습니다. "클래스 객체에서는 무조건 전위 연산자가 유리하다"는 속설은 복잡한 객체 복사 생성자가 호출되는 특수한 상황에서 기인한 것이며, 현대적인 컴파일러 최적화 환경에서는 그 차이가 미미하거나 없는 경우가 많습니다.

결론적으로, 기본 타입의 경우 두 연산자의 성능 차이는 없으며, 어셈블리 수준에서 프로그램은 불필요한 부하를 임의로 추가하지 않습니다.

태그: C++ Assembly Operator Overloading compiler optimization Memory Management

7월 8일 05:06에 게시됨