C++ 객체 수명 주기와 RVO/NRVO 컴파일러 최적화 심층 분석

함수 호출 과정에서의 객체 수명 주기와 메서드 호출

사례 1: 객체 초기화, 복사, 그리고 임시 객체의 동작

C++에서 객체가 생성되고 복사되며 소멸되는 과정을 정확히 이해하는 것은 성능 최적화의 첫걸음입니다. 다음 코드는 다양한 초기화 및 대입 상황에서 어떤 생성자와 연산자가 호출되는지 보여줍니다.

#include <iostream>

class DataHolder {
public:
    DataHolder(int val = 10) : value_(val) {
        std::cout << "Constructor(int)\n";
    }
    ~DataHolder() {
        std::cout << "Destructor\n";
    }
    DataHolder(const DataHolder& other) : value_(other.value_) {
        std::cout << "Copy Constructor\n";
    }
    DataHolder& operator=(const DataHolder& other) {
        std::cout << "Assignment Operator\n";
        value_ = other.value_;
        return *this;
    }

private:
    int value_;
};

int main() {
    DataHolder d1;
    DataHolder d2(d1);
    DataHolder d3 = d1; 
    
    DataHolder d4 = DataHolder(20); 
    
    d4 = d2;
    d4 = DataHolder(30); 
    d4 = 40; 

    const DataHolder& ref = DataHolder(50); 

    return 0;
}
  • DataHolder d3 = d1;은 대입 연산자가 아닌 복사 생성자를 호출합니다. 변수를 정의하는 동시에 초기화할 때는 항상 복사 생성자가 사용됩니다.
  • DataHolder d4 = DataHolder(20);에서 DataHolder(20)은 임시 객체를 생성하는 것처럼 보이지만, 현대 C++ 컴파일러의 복사 생략(Copy Elision) 최적화에 의해 임시 객체 생성 없이 d4가 직접 생성됩니다.
  • d4 = d2;는 이미 존재하는 객체에 값을 덮어쓰므로 대입 연산자가 호출됩니다.
  • d4 = DataHolder(30);은 명시적 임시 객체를 생성하여 대입한 후, 해당 문장이 끝나면 임시 객체를 즉시 소멸시킵니다. 반면 d4 = 40;은 컴파일러가 적절한 생성자를 찾아 암시적 임시 객체를 생성한 뒤 대입합니다.
  • const DataHolder& ref = DataHolder(50);에서 상수 왼쪽값 참조(Const Lvalue Reference)는 임시 객체의 수명을 참조 변수의 수명까지 연장시킵니다. 따라서 임시 객체는 문장이 끝난 후 소멸되지 않고 main 함수가 종료될 때까지 안전하게 유지됩니다.

사례 2: 전역 변수, 정적 변수 및 동적 할당에서의 객체 생성

객체의 저장 공간과 수명에 따라 생성 및 소멸 시점이 어떻게 달라지는지 확인해 봅니다.

#include <iostream>

class Metric {
public:
    Metric(int x = 5, int y = 5) : x_(x), y_(y) {
        std::cout << "Constructor(int, int)\n";
    }
    ~Metric() { 
        std::cout << "Destructor\n"; 
    }
    Metric(const Metric& src) : x_(src.x_), y_(src.y_) {
        std::cout << "Copy Constructor\n";
    }
    Metric& operator=(const Metric& src) {
        x_ = src.x_;
        y_ = src.y_;
        std::cout << "Assignment Operator\n";
        return *this;
    }

private:
    int x_;
    int y_;
};

Metric global_m1(10, 10); 

int main() {
    Metric local_m(20, 20); 
    Metric copy_m = local_m;    

    static Metric static_m = Metric(30, 30); 

    local_m = Metric(40, 40); 
    local_m = 60; 

    Metric* ptr1 = new Metric(70, 70);   
    Metric* ptr2 = new Metric[2];        
    const Metric& ref_m = Metric(90, 90); 

    delete ptr1;   
    delete[] ptr2; 
    return 0;
}

Metric global_m2(100, 100); 
  • 전역 변수(global_m1, global_m2)는 main 함수 진입 전에 정의된 순서대로 생성되며, 프로그램 종료 시 역순으로 소멸됩니다.
  • 정적 지역 변수(static_m)는 최초 실행 시 한 번만 생성되며, 전역 변수와 마찬가지로 프로그램 종료 시 소멸됩니다. Metric(30, 30) 형태의 초기화도 복사 생략 최적화가 적용되어 직접 생성됩니다.
  • 동적 할당(new)된 객체는 delete를 명시적으로 호출해야만 소멸자가 실행됩니다. 배열의 경우 delete[]를 사용해야 모든 요소의 소멸자가 올바르게 호출됩니다.

사례 3: 함수 인자 전달과 반환 값 최적화(RVO/NRVO)

함수를 드나들 때 발생하는 객체의 복사 오버헤드는 C++ 성능 저하의 주요 원인 중 하나입니다.

#include <iostream>

class Entity {
public:
    Entity(int data = 10) : data_(data) {
        std::cout << "Constructor\n";
    }
    ~Entity() {
        std::cout << "Destructor\n";
    }
    Entity(const Entity& other) : data_(other.data_) {
        std::cout << "Copy Constructor\n";
    }
    Entity& operator=(const Entity& other) {
        std::cout << "Assignment Operator\n";
        data_ = other.data_;
        return *this;
    }
    int getData() const { return data_; }

private:
    int data_;
};

Entity processEntity(Entity e) {
    int val = e.getData();
    Entity temp(val);
    return temp; 
}

int main() {
    Entity e1;
    Entity e2;
    e2 = processEntity(e1);
    return 0;
}

위의 코드에서 최적화가 적용되지 않는다고 가정할 때, 다음과 같은 불필요한 호출이 발생합니다.

  1. e1processEntity의 매개변수 e로 값 전달되며 복사 생성자가 호출됩니다.
  2. 함수 내부에서 temp 객체가 생성자를 통해 만들어집니다.
  3. temp를 반환할 때, 호출자의 스택 프레임에 있는 익명의 반환 값 객체로 복사 생성자가 호출됩니다.
  4. 지역 변수 temp와 매개변수 e소멸자가 호출됩니다.
  5. 반환된 익명 객체가 e2대입 연산자를 통해 할당된 후, 익명 객체의 소멸자가 호출됩니다.

현대 컴파일러는 반환 값 최적화(RVO)명명된 반환 값 최적화(NRVO)를 통해 이러한 복사 과정을 생략하지만, 모든 환경에서 보장되지는 않습니다. 따라서 개발자가 직접 최적화 규칙을 준수하는 코드를 작성하는 것이 중요합니다.

객체 최적화를 위한 세 가지 핵심 규칙

규칙 1: 매개변수 전달 시 값 전달 대신 참조 전달 우선

함수의 매개변수로 객체를 받을 때는 값 전달(Pass-by-value) 대신 상수 참조(Const Reference)를 사용해야 합니다.

// 최적화 전: 불필요한 복사 생성자 및 소멸자 호출 발생
Entity processEntity(Entity e);

// 최적화 후: 복사 오버헤드 제거
Entity processEntity(const Entity& e);

참조 전달을 사용하면 매개변수를 위한 새로운 객체가 생성되지 않으므로, 복사 생성자와 소멸자 호출 오버헤드를 완전히 제거할 수 있습니다.

규칙 2: 객체 반환 시 지역 변수 대신 임시 객체 반환 우선

함수에서 객체를 반환할 때는 지역 변수를 반환하기보다 임시 객체(익명 객체)를 직접 생성하여 반환하는 것이 RVO(Return Value Optimization)를 유도하는 데 유리합니다.

Entity createEntity(int val) {
    // 지역 변수를 반환하는 대신 임시 객체를 직접 반환
    return Entity(val);
}

임시 객체를 반환하면 컴파일러가 반환 값 객체를 호출자의 메모리 공간에 직접 생성하도록 최적화할 수 있어, 추가적인 복사 생성을 방지할 수 있습니다.

규칙 3: 반환값 수신 시 대입 연산 대신 초기화 방식 우선

객체를 반환하는 함수의 결과를 받을 때는 이미 선언된 변수에 대입 연산자(=)를 사용하는 대신, 변수를 선언과 동시에 초기화해야 합니다.

int main() {
    Entity e1;
    
    // 대입 연산 사용: 대입 연산자 및 임시 객체 소멸 오버헤드 발생
    Entity e2;
    e2 = createEntity(20); 

    // 초기화 방식 사용: 복사 생략(Copy Elision)을 통한 직접 생성
    Entity e3 = createEntity(30); 
    
    return 0;
}

초기화 방식을 사용하면 컴파일러가 반환된 임시 객체를 목표 변수(e3)의 메모리 위치에 직접 구성할 수 있습니다. 이를 통해 대입 연산자 호출과 임시 객체의 생성 및 소멸 과정을 생략하여 함수 호출 오버헤드를 최소화할 수 있습니다.

태그: C++ RVO NRVO CopyConstructor CompilerOptimization

8월 4일 21:20에 게시됨