icon
14장 : 현대 C++ 기능

move 의미론과 rvalue 참조

이번 장에서는 C++11에서 도입된 또 하나의 혁신적인 개념인 이동 시맨틱(Move Semantics) 과 이를 가능하게 하는 Rvalue 참조(Rvalue References) 에 대해 알아보겠습니다.

이동 시맨틱은 객체 내부의 값비싼 자원(동적 할당된 메모리, 파일 핸들 등)을 복사하는 대신 '이동'함으로써, 불필요한 복사 비용을 줄이고 프로그램의 성능을 크게 향상시키는 데 기여합니다.

이는 특히 대용량 데이터를 다루는 클래스에서 그 효과가 두드러집니다.


Rvalue와 Lvalue의 재정의

이동 시맨틱을 이해하기 위해서는 먼저 C++의 값 범주(Value Categories) 개념을 명확히 알아야 합니다.

C++11 이전에는 모든 표현식을 lvalue 또는 rvalue로 분류했습니다.

C++11 이후에는 이 분류가 좀 더 세분화되었습니다.

  • Lvalue (Left Value): 할당 연산자(=)의 왼쪽에 올 수 있는 표현식. 이름이 있고 주소를 가질 수 있는 객체입니다. (예: 변수, *ptr, arr[index])

    Lvalue 예시
    int x = 10; // x는 lvalue
    int* p = &x; // p는 lvalue, *p는 lvalue
  • Rvalue (Right Value): 할당 연산자(=)의 오른쪽에만 올 수 있는 표현식. 임시 객체, 리터럴, 이름 없는 값 등입니다. 주소를 가질 수 없고, 표현식이 끝나면 소멸됩니다. (예: 10, a + b, 함수 반환 값)

    Rvalue 예시
    int y = 5;
    int z = x + y; // (x + y)는 rvalue
    int result = func(); // func()의 반환 값은 rvalue (임시 객체)

C++11은 lvaluervalue 외에 다음과 같은 새로운 값 범주를 도입하여 이동 시맨틱의 기반을 마련했습니다:

  • prvalue (Pure Rvalue): 임시 객체 또는 리터럴처럼, 값을 생산하지만 명확한 주소를 가지지 않는 표현식. (예: 10, a + b, func()의 반환 값)
  • xvalue (eXpiring Value): 소멸될 준비가 되어 있지만 여전히 주소를 가질 수 있는 표현식. std::move의 결과나 rvalue 참조로 캐스팅된 lvalue 등이 여기에 해당합니다.
  • glvalue (Generalized Lvalue): 주소를 가질 수 있는 표현식 (lvaluexvalue를 포함).
  • rvalue (generalized Rvalue): prvaluexvalue를 포함하는 개념. 즉, 이동될 수 있는 값.

우리가 주로 다룰 rvalueprvaluexvalue를 통칭하며, "곧 소멸될 값이므로, 이 값의 자원을 훔쳐와도 안전하다"는 의미를 가집니다.


Rvalue 참조 (&&)

C++11에서는 rvalue 참조(&&) 라는 새로운 참조 타입이 도입되었습니다.

기존의 참조(&)는 lvalue reference라고 부르며 lvalue에만 바인딩될 수 있었습니다.

반면, rvalue referencervalue에만 바인딩될 수 있습니다.

Rvalue 참조 선언 형식
타입&& 변수이름 = rvalue_표현식;
Lvalue와 Rvalue 참조 예시
#include <iostream>

void printValue(int& x) { // Lvalue 참조 (x는 Lvalue여야 함)
    std::cout << "Lvalue reference: " << x << std::endl;
}

void printValue(int&& x) { // Rvalue 참조 (x는 Rvalue여야 함)
    std::cout << "Rvalue reference: " << x << std::endl;
}

int main() {
    int a = 10; // a는 lvalue
    printValue(a); // Lvalue reference 호출

    // printValue(10); // 컴파일 오류: 10은 rvalue인데, lvalue reference를 받으려고 함
    printValue(10); // Rvalue reference 호출
    printValue(a + 5); // (a + 5)는 rvalue, Rvalue reference 호출

    int b = 20;
    // int& ref_b = b + 1; // 컴파일 오류: b+1은 rvalue
    int&& ref_b_rvalue = b + 1; // Rvalue 참조는 rvalue에 바인딩 가능
    std::cout << "ref_b_rvalue: " << ref_b_rvalue << std::endl; // 출력: 21

    // ref_b_rvalue는 이제 Lvalue로 취급됩니다! (나중에 설명)
    printValue(ref_b_rvalue); // Lvalue reference 호출
    printValue(std::move(ref_b_rvalue)); // std::move 사용 시 Rvalue reference 호출

    return 0;
}

핵심: rvalue reference는 임시 객체나 명확한 주소가 없는 값(즉, rvalue)에 바인딩되어, 해당 rvalue의 자원을 '훔쳐올' 수 있는 기회를 제공합니다.


이동 시맨틱 (Move Semantics)

rvalue reference가 도입된 주된 이유는 이동 시맨틱을 구현하기 위함입니다.

이동 시맨틱은 객체를 복사하는 대신, 객체 내부의 자원을 '이동'시키는 개념입니다.

복사 vs 이동

  • 복사(Copy): 원본 객체의 모든 데이터를 새로운 메모리 공간에 복제합니다. 대용량 데이터의 경우 시간과 메모리 낭비가 심합니다. (복사 생성자, 복사 대입 연산자)
  • 이동(Move): 원본 객체가 소유한 자원(예: 힙 메모리 포인터)을 새로운 객체로 단순히 '이전'시키고, 원본 객체는 유효하지만 자원을 소유하지 않는 상태로 만듭니다. 복사본을 만들 필요가 없어 훨씬 효율적입니다. (이동 생성자, 이동 대입 연산자)

이동 생성자(Move Constructor)와 이동 대입 연산자(Move Assignment Operator): 이동 시맨틱을 구현하려면 클래스에 이동 생성자와 이동 대입 연산자를 명시적으로 정의해야 합니다. 이들은 rvalue reference를 매개변수로 받습니다.

이동 생성자와 이동 대입 연산자 예시
class MyVector {
private:
    int* _data;
    size_t _size;
    size_t _capacity;

public:
    // 일반 생성자
    MyVector(size_t size) : _size(size), _capacity(size), _data(new int[size]) {
        std::cout << "Constructor: Allocated " << _size * sizeof(int) << " bytes.\n";
    }

    // 소멸자
    ~MyVector() {
        if (_data) {
            std::cout << "Destructor: Deallocating " << _size * sizeof(int) << " bytes.\n";
            delete[] _data;
        }
    }

    // 복사 생성자 (Deep Copy)
    MyVector(const MyVector& other) : _size(other._size), _capacity(other._capacity) {
        _data = new int[_capacity];
        for (size_t i = 0; i < _size; ++i) {
            _data[i] = other._data[i];
        }
        std::cout << "Copy Constructor: Copied " << _size * sizeof(int) << " bytes.\n";
    }

    // 복사 대입 연산자 (Deep Copy)
    MyVector& operator=(const MyVector& other) {
        if (this != &other) {
            if (_data) delete[] _data;
            _size = other._size;
            _capacity = other._capacity;
            _data = new int[_capacity];
            for (size_t i = 0; i < _size; ++i) {
                _data[i] = other._data[i];
            }
        }
        std::cout << "Copy Assignment: Copied " << _size * sizeof(int) << " bytes.\n";
        return *this;
    }

    // ************* 이동 생성자 (Move Constructor) *************
    MyVector(MyVector&& other) noexcept
        : _data(other._data), _size(other._size), _capacity(other._capacity) {
        // 원본 객체의 자원 포인터를 nullptr로 설정하여 이중 해제 방지
        other._data = nullptr;
        other._size = 0;
        other._capacity = 0;
        std::cout << "Move Constructor: Moved " << _size * sizeof(int) << " bytes.\n";
    }

    // ************* 이동 대입 연산자 (Move Assignment Operator) *************
    MyVector& operator=(MyVector&& other) noexcept {
        if (this != &other) {
            if (_data) delete[] _data; // 기존 자원 해제 (RAII)

            _data = other._data;
            _size = other._size;
            _capacity = other._capacity;

            other._data = nullptr; // 원본 객체 자원 무효화
            other._size = 0;
            other._capacity = 0;
        }
        std::cout << "Move Assignment: Moved " << _size * sizeof(int) << " bytes.\n";
        return *this;
    }

    // 편의를 위한 함수
    void print_info() const {
        std::cout << "MyVector Info: size=" << _size << ", capacity=" << _capacity
                  << ", data_ptr=" << (void*)_data << std::endl;
    }
};

// MyVector 객체를 반환하는 함수 (RVO/NRVO가 적용될 수 있음)
MyVector createVector(size_t size) {
    return MyVector(size);
}

int main() {
    std::cout << "--- Scenario 1: Copy vs Move ---" << std::endl;
    MyVector vec1(10); // Constructor
    vec1.print_info();

    std::cout << "\nAttempting copy (Copy Constructor or Copy Assignment)\n";
    MyVector vec2 = vec1; // Copy Constructor 호출 (복사 발생)
    vec2.print_info();
    vec1.print_info(); // 원본은 그대로 유지

    std::cout << "\nAttempting move (Move Constructor or Move Assignment)\n";
    MyVector vec3 = std::move(vec1); // Move Constructor 호출 (이동 발생)
    vec3.print_info();
    vec1.print_info(); // vec1은 자원을 잃었음을 확인 (nullptr)

    std::cout << "\n--- Scenario 2: Function Return Value (RVO/NRVO vs Move) ---" << std::endl;
    // C++ 컴파일러는 일반적으로 RVO/NRVO(Return Value Optimization/Named Return Value Optimization)를 수행하여
    // 불필요한 복사/이동을 피하려고 시도합니다.
    // 따라서 아래 코드에서는 이동 생성자가 호출되지 않을 수도 있습니다.
    // 이를 명확히 보려면 컴파일러 최적화 옵션을 끄거나 (권장하지 않음),
    // std::vector와 같은 컨테이너의 push_back 등에서 이동을 관찰하는 것이 좋습니다.
    
    MyVector vec4(1); // Constructor
    std::cout << "\nAssigning return value from function:\n";
    vec4 = createVector(20); // createVector에서 MyVector(20) 생성 -> (RVO/NRVO) -> vec4로 이동 대입
                             // 실제 컴파일러에 따라 이동 생성자 + 이동 대입 또는 그냥 생성자만 호출될 수 있음.

    std::cout << "\n--- Scenario 3: std::vector::push_back with move ---" << std::endl;
    std::vector<MyVector> vec_of_vectors;
    vec_of_vectors.reserve(2); // 재할당으로 인한 복사/이동 방지
    
    vec_of_vectors.push_back(MyVector(5)); // 임시 객체 (rvalue)이므로 이동 생성자 호출 (C++11 이후)
    vec_of_vectors.push_back(std::move(vec_of_vectors[0])); // vec_of_vectors[0]의 자원을 이동.
                                                             // vec_of_vectors[0]는 이제 비어있는 상태
                                                             // (하지만 vector는 요소들을 재배치하여 빈 공간 채울 수 있음)
    vec_of_vectors[0].print_info();
    vec_of_vectors[1].print_info();

    std::cout << "End of main." << std::endl;
    return 0;
} // 모든 MyVector 객체의 소멸자가 호출됨

이동 생성자와 이동 대입 연산자는 noexcept로 선언하는 것이 일반적입니다.

이는 이동 작업 중 예외가 발생하지 않음을 컴파일러에게 알려주어, std::vector와 같은 컨테이너가 재할당 시 이동 대신 복사를 선택하는 것을 방지하고 성능상의 이점을 얻을 수 있게 합니다.


std::move 함수

std::move는 객체를 실제로 '이동'시키는 함수가 아닙니다.

std::move는 단지 주어진 lvaluervalue reference로 캐스팅(static_cast) 하는 함수입니다.

이렇게 캐스팅된 rvalue reference는 이동 생성자나 이동 대입 연산자가 오버로드되어 있다면, 해당 연산자를 호출하도록 유도합니다.

주의: std::move를 사용한 후에는 원본 객체의 상태를 더 이상 신뢰할 수 없습니다. 원본 객체는 '유효하지만 지정되지 않은(valid but unspecified)' 상태가 됩니다. 즉, 소멸자는 안전하게 호출될 수 있지만, 그 외의 다른 연산을 수행하기 전에 재초기화하거나, 자원을 가져간 상태임을 인지하고 사용해야 합니다.

std::move 사용 예시
MyVector vec1(10);
MyVector vec3 = std::move(vec1); // vec1을 rvalue로 간주하여 이동 생성자 호출

완벽 전달과 std::forward (고급)

이동 시맨틱과 rvalue reference의 또 다른 중요한 활용은 완벽 전달(Perfect Forwarding) 입니다.

이는 함수 템플릿의 매개변수를 다른 함수로 전달할 때, 원본 매개변수의 값 범주(lvalue/rvalue)와 const/volatile 한정자를 그대로 유지하여 전달하는 기법입니다.

완벽 전달을 구현하기 위해서는 T&& (Universal Reference / Forwarding Reference)std::forward 를 사용합니다.

  • T&&: 함수 템플릿에서 T가 추론될 때, T&&lvalue가 오면 Lvalue reference (T&)로, rvalue가 오면 Rvalue reference (T&&)로 추론됩니다. 이를 Universal Reference 또는 Forwarding Reference라고 부릅니다.
  • std::forward<T>(arg): argT의 값 범주에 맞게 static_cast하여 전달합니다.
완벽 전달 예시
#include <iostream>
#include <utility> // std::forward, std::move

void process(int& value) { std::cout << "Processing Lvalue: " << value << std::endl; }
void process(int&& value) { std::cout << "Processing Rvalue: " << value << std::endl; }

template<typename T>
void wrapper(T&& arg) { // arg는 Universal Reference
    std::cout << "Wrapper received: ";
    process(std::forward<T>(arg)); // arg를 원래의 값 범주로 전달
}

int main() {
    int lval = 10;
    wrapper(lval); // lval은 Lvalue이므로 process(int&) 호출
    wrapper(20);   // 20은 Rvalue이므로 process(int&&) 호출

    wrapper(std::move(lval)); // lval을 Rvalue로 캐스팅하여 전달, process(int&&) 호출

    return 0;
}

std::forward를 사용하면 wrapper 함수가 arg를 마치 직접 호출하는 것처럼, arglvalue/rvalue 속성을 process 함수로 그대로 전달할 수 있습니다.

이는 라이브러리 개발에서 매우 중요합니다.


이동 시맨틱의 장점

  • 성능 향상: 불필요한 복사 비용을 줄여 특히 대용량 데이터를 다루는 경우 프로그램의 실행 속도를 크게 높입니다.
  • 자원 효율성: 이미 존재하는 자원을 재활용하므로 메모리 할당 및 해제 오버헤드를 줄입니다.
  • 예외 안전성: 이동 작업이 noexcept로 선언될 수 있다면, 컨테이너 재할당 등에서 더 강력한 예외 안전성을 보장받을 수 있습니다.
  • 스마트 포인터의 효율성: std::unique_ptrmove-only 특성은 이동 시맨틱 덕분에 가능합니다.