C, C++/C++ 언어 / / 2024. 6. 25.

[C++] 우측값 참조( rvalue reference )와 이동 생성자( move constructor )

우측값 참조( rvalue reference )

우측값 참조란 우측값을 참조하는 데이터 타입을 말합니다.

이 글에서는 우측값 참조를 왜 만들었으며, 이 참조를 사용하는 법에 대하여 설명하겠습니다.

 

먼저, 시간적으로나 공간적으로 매우 큰 데이터를 가진 클래스 객체를 먼저 생각해 보겠습니다.

 

아래의 클래스는 생성자에서 메모리를 할당하고, 소멸자에서 할당된 메모리를 자동으로 삭제하는 간단한 클래스입니다.

실제로 할당하는 메모리 양은 작지만, 큰 양의 데이터를 다룬다고 가정합시다.

이 클래스는 복사 생성자( copy constructor )와, 복사 대입 연산자( copy assignment operator )를 구현하고 있습니다.

#define BUFFER_SIZE 100

class CBuffer{

    int* m_pBuffer;

    void CopyData(const CBuffer& other){
        if ( m_pBuffer){	// 기존의 데이터 삭제
            delete [] m_pBuffer; m_pBuffer = NULL;
        }
        m_pBuffer = new int[BUFFER_SIZE];   // 메모리 할당
        
        for( int i = 0; i < BUFFER_SIZE; i++){  // 깊은 복사
            m_pBuffer[i] = other.m_pBuffer[i];
        }
    }

public:
    
    // 기본 생성자
    CBuffer(){  
        cout << "CBuffer Default Constructor\n";
        m_pBuffer = new int[BUFFER_SIZE];   // 메모리 할당
    }

    // 복사 생성자
    CBuffer( const CBuffer& other) {
        cout << "CBuffer Copy Constructor\n";
        m_pBuffer = NULL;
        CopyData(other);
    }

    // 대입 연산자
    CBuffer& operator=(const CBuffer& other){
        cout << "CBuffer Assignment Operator\n";
        CopyData(other);
        return *this;
    }

    ~CBuffer(){
        cout << "CBuffer Destructor\n";

        if ( m_pBuffer){
            delete [] m_pBuffer;    // 할당된 메모리 삭제
        }
    }
};

이 클래스는 복사 생성자와 복사 대입 연산자에서 "깊은 복사"를 구현하고 있습니다.

메모리를 할당하는 클래스는 의도적으로 다른 코드를 구현하는 것이 아니라면, "깊은 복사"를 수행하는 복사 생성자를 구현하는 것이 바람직합니다.

 

"깊은 복사"와 복사 생성자에 관한 내용은 여기에 정리되어 있습니다.

 

[C++] 복사 생성자 (Copy Constructor)와 복사 생략

복사 생성자복사 생성자란 같은 타입의 객체를 인자로 받아 그 객체의 데이터를 가지고 초기화를 수행하는 생성자를 말합니다. 이 글에서 계속 사용하게 될 클래스를 하나 만들어 보겠습니다.

codingembers.tistory.com

 

이제, 위의 클래스 CBuffer를 가지고 아래의 코드를 실행하면 어떻게 되는지 알아보겠습니다.

프로그램 실행을 main 함수에서 리턴하기 전까지 수행했습니다.

CBuffer Func(){
    CBuffer buffer;
    return buffer;
}

int main(){

    CBuffer buff;
    buff = Func();    // assignment
    
    return 0;	// 여기까지만 실행
}

▼출력

CBuffer Default Constructor
CBuffer Default Constructor
CBuffer Assignment Operator
CBuffer Destructor

출력된 결과를 보면,

먼저, Func() 함수 내에서 객체가 하나 생성되고, main() 함수 내에서 buff 객체가 하나 생성됩니다.

그리고, 복사 대입( assignment ) 연산자가 호출됩니다.

마지막으로, Func()의 임시 객체가 삭제됩니다.

 

의도한 대로 제대로 작동을 했습니다.

그러나, 이렇게 작동하는 것에 아쉬움이 없는 것은 아닙니다.

 

복사 대입 연산자를 호출하는 부분을 보면,

buff 객체의 기존의 데이터를 삭제하고, 임시 객체의 데이터를 복사해서 새로운 데이터를 만듭니다.

그리고, 임시 객체는 함수 종료와 동시에 삭제됩니다.

 

그러나, 이 과정을 거치는 것보단,

buff 객체의 기존의 데이터를 삭제하고 임시 객체의 데이터를 그냥 buff 객체로 옮기는 것이 나아 보입니다.

무려 이전의 50% 과정이 생략됩니다.

이를 코드로 작성하면 아마 이렇게 될 것입니다.

void MoveData(CBuffer& other){
    
    if ( m_pBuffer){	// 기존의 데이터 삭제
        delete [] m_pBuffer; m_pBuffer = NULL;
    }
    
    m_pBuffer = other.m_pBuffer;	// 다른 객체의 데이터 이동
    other.m_pBuffer = NULL;
}

 

이렇게, 복사 대신 이동을 사용하는 것이 유리할 때가 있습니다.

그중, 가장 필요한 곳은 우측값을 다룰 때입니다.

왜냐하면, 우측값은 임시 객체 형식으로 바로 삭제되기 때문에, 데이터를 복사하고 자신의 데이터를 삭제하는 과정이 너무 큰 낭비임을 알게 되었기 때문입니다.

 

좌측값과 우측값에 관한 내용은 여기에 정리해 두었습니다.

 

[C++] 좌측값( l-value )과 우측값( r-value ) 구분하기

좌측값( l-value )과 우측값( r-value ) 좌측값과 우측값이란 말은 대입 연산자 '='의 왼쪽에 위치하느냐 오른쪽에 위치하느냐에 따라 나눠지는 말입니다. int A;10 = A;위의 코드를 보면, 대입 연산자의

codingembers.tistory.com

 

그래서, C++은 불필요한 복사 과정을 해결하기 위해서, 버전 11에 move semantics를 도입했습니다.

move sementics는 이동에 관한 규칙들과 C++ 도구들의 집합이라고 할 수 있습니다.

 

그중에 하나가, 우측값 참조( rvalue reference )입니다.

 

시작에서 말했듯이, 우측값 참조는 우측값을 참조하는 데이터 타입입니다.

기존의 참조와 구분하기 위해 && 기호를 사용합니다.

반대로, 기존의 참조는 좌측값을 참조하므로 좌측값 참조( lvalue reference )라고 합니다.

 

이러한 참조 데이터 타입을 만듦으로써, 어떤 일을 할 수 있게 될까요?

 

이동 생성자( move constructor )와 이동 대입 연산자( move assignment operator )

C++은 우측값 참조를 도입함으로써, 이를 사용하여 복사 생성자를 오버로딩( overloading ) 할 수 있게 됩니다.

이 생성자를 이동 생성자( move constructor )라고 합니다.

 

마찬가지로, 복사 대입 연산자를 오버로딩한 연산자를 이동 대입 연산자( move assignment operator )라고 합니다.

 

이동 생성자와 이동 대입 연산자의 구현은 아래와 같습니다.

( 기존의 코드는 숨기고, 추가된 생성자와 연산자만을 표시했습니다. )

class CBuffer{

    int* m_pBuffer;

    void MoveData(CBuffer&& other) noexcept {
        swap(m_pBuffer, other.m_pBuffer);
    }

public:
    
    // 이동 생성자
    CBuffer( CBuffer&& other) noexcept {
        cout << "CBuffer Move Constructor\n";
        swap( m_pBuffer, other.m_pBuffer);
    }

    // 이동 대입 연산자
    CBuffer& operator=(CBuffer&& other) noexcept {
        cout << "CBuffer Move Assignment Operator\n";
        MoveData( std::move(other));
        return *this;
    }
};

 

복사 생성자나 복사 대입 연산자는 구현을 하지 않았을 때 컴파일러가 자동으로 만들어 줍니다.

그러나, 다음과 같은 경우에는 이동 생성자와 이동 대입 연산자는 구현을 안 하더라도, 컴파일러가 자동으로 구현하지 않습니다.

 

  • 복사 생성자가 복사 대입 연산자가 있는 경우
  • 소멸자가 있는 경우
  • 이동 생성자나 이동 대입 연산자가 있는 경우

 

이런 경우, 이동 생성자 대신 복사 생성자가 호출됩니다.

마찬가지로, 이동 대입 연산자 대신 복사 대입 연산자가 호출됩니다.

 

이렇게 컴파일러가 자동으로 작성으로 하는 함수들에 관한 내용은 여기서 볼 수 있습니다.

 

[C++] 컴파일러가 자동으로 작성하는 멤버 함수들

특수 멤버 함수( special member functions )컴파일러가 자동으로 작성하는 특수 멤버 함수는 모두 6개인데, 구체적으로는 기본 생성자, 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입

codingembers.tistory.com

 

이제, 위에서 실행했던 코드는 이동 생성자와 이동 대입 연산자를 추가한 후, 어떻게 바뀌었을까요?

CBuffer Func(){
    CBuffer buffer;
    return buffer;
}

int main(){

    CBuffer buff;
    buff = Func();    // 복사 대입 연산자 호출?
    
    return 0;	// 여기까지만 실행
}

▼출력

CBuffer Default Constructor
CBuffer Default Constructor
CBuffer Move Assignment Operator
CBuffer Destructor

이전 결과와 비교해 보면, "Assignment"에서 "Move Assignment"로 바뀌었음을 알 수 있습니다.

함수 Func()우측값이기 때문에, 오버로딩을 통해 이동 대입 연산자가 호출되고,

그 함수에서 MoveData 함수를 다시 호출해서 객체가 가지고 있던 데이터를 복사하는 대신, 이동을 하고 있습니다.

void MoveData(CBuffer&& other) noexcept {
    swap(m_pBuffer, other.m_pBuffer);	// 오른쪽 참조가 참조하는 객체의 데이터 이동
}

이렇게 우측값 참조를 사용하여, 불필요한 데이터 복사 과정을 제거하게 되었습니다.

 

그리고, 이동 생성자와 이동 대입 연산자에 쓰인 noexcept는 이 함수들이 예외를 발생시키지 않는다는 것을 명시하는 키워드입니다.

CBuffer 객체를 사용하려는 객체들은 이 정보를 사용해서 적절한 함수를 호출하게 될 것입니다.

결과적으로, 이동 생성자와 이동 대입 연산자를 통한 CBuffer의 성능이 향상될 수 있게 됩니다.

 

noexcept에 관한 자세한 내용은 여기에서 볼 수 있습니다.

 

[C++] noexcept 키워드를 사용하는 이유

noexcept 키워드noexcept는 함수 이름의 끝에 붙는 키워드로, 이 키워드가 붙은 함수는 예외를 발생시키지 않는다는 것을 명시합니다. 참고로, noexcept( true )로 쓸 수도 있는데, 이것은 noexcept와 같은

codingembers.tistory.com

 

그런데, 이동 대입 연산자의 코드 중, std::move 함수는 어떤 함수일까요?

CBuffer& operator=(CBuffer&& other) noexcept {
    cout << "CBuffer Move Assignment Operator\n";
    
    MoveData( std::move(other));	// std::move 함수 호출
    return *this;
}

 

std::move() 함수

위의 이동 대입 연산자에서 인자인 other는 예상외로 좌측값 참조입니다.

이것은 이동 문법( move semantics )에서 정한 규칙을 봐야 합니다.

우측값 참조라 정의한 것들도 좌측값 혹은 우측값이 될 수 있다. 
이를 판단하는 기준은, 만일 이름이 있다면 좌측값, 없다면 우측값이다.

 

정말 그렇게 처리하고 있는지 확인해 봅시다.

int main(){

    CBuffer buff;

    CBuffer&& rref = Func();    // 우측값 참조 ?
    buff = rref;       // 이동 대입 연산자 호출 ?
    
    return 0;
}

▼출력

CBuffer Default Constructor
CBuffer Default Constructor
CBuffer Assignment Operator

우측값 참조가 rref 이름을 가지고 있으므로, 좌측값 참조로 간주됩니다.

따라서, 이동 대입 연산자가 아니라, 복사 대입 연산자가 호출됩니다.

 

이런 방식을 설계한 이유는, 위의 코드처럼 오른쪽 참조가 이름이 있게 된 상태에서, 이동 대입 연산자를 통해 데이터를 이동했다고 가정해 봅시다.

그럼,  현재 오른쪽 참조는 데이터가 없는 상태입니다. 

그러나, 아직 이름을 사용할 수 있는 범위를 벗어나지 않았기 때문에, 이 참조를 통해서 계속 데이터에 접근할 수 있고, 그 결과가 어떻게 될 것인지 예상하기 힘들어지게 될 것입니다.

 

그래서, 실제로 아래와 같이 이동 대입 연산자를 코딩하면, 오류 메시지를 받습니다.

CBuffer& operator=(CBuffer&& other) noexcept {
    cout << "CBuffer Move Assignment Operator\n";
    
    MoveData( other );	// other는 좌측값 참조
    return *this;
}

other가 이름이므로 좌측값 참조가 되고, MoveData 함수는 우측값 참조를 인자로 받기 때문에 컴파일 오류를 만나게 되는 겁니다.

그래서, move semantics에서는 이 문제를 해결하기 위한 도구로 std::move 함수를 제공합니다.

 

T&& std::move(T& a) noexcept { return static_cast<T&&>(a); }

이 함수는 T 타입 객체의 좌측값 참조를 받아서, 우측값 참조를 반환하는 함수입니다.

(실제 구현된 코드를 보면 좀 다른데, 결과는 위 함수의 형태가 됩니다.)

 

이 함수가 반환하는 우측값 참조는 이름이 없으므로, 반환과 동시에 우측값은 삭제됩니다.

따라서, 이름으로 이동된 데이터에 계속 접근하는 문제를 해결했습니다.

 

이제, 위의 컴파일 오류를 std::move 함수를 사용하여 제대로 동작하도록 만들 수 있습니다.

CBuffer& operator=(CBuffer&& other) noexcept {
    cout << "CBuffer Move Assignment Operator\n";
    
    MoveData( std::move(other));	// 좌측값 참조를 우측값 참조로 변환
    return *this;
}

 

이번엔, 이동 생성자를 사용하여 다른 객체를 데이터를 가져오는 코드를 작성해 보겠습니다.

int main(){

    CBuffer buff1;

    CBuffer buff2 = std::move(buff1);   // 이동 생성자 사용
    
    return 0;   // 여기까지 실행
}

▼출력

CBuffer Default Constructor
CBuffer Move Constructor

buff1의 데이터를 buff2로 이동하는 코드입니다.

현재 buff1의 데이터는 없는 상태이므로, 사용 시에 주의해야 합니다.

 

move sementics의 실제 적용

STL 함수 중에 std::swap 함수가 있습니다.

아시다시피, swap는 두 변수의 값을 교환하는 함수입니다.

 

실제 구현은 다음과 같습니다.

template <typename T>
void Swap(T &lhs, T &rhs)
{
    T t = lhs;
    lhs = rhs;
    rhs = t;
}

 

이 함수를 사용해서, 아래 객체의 내용을 바꾸려고 합니다.

std::vector<int> arrA(1'000'000, 0);
std::vector<int> arrB(1'000'000, 1);
Swap(arrA, arrB);

그 과정은 아마 이럴 것입니다.

  • 대상 객체 arrA의 기존의 메모리 삭제
  • arrA에 복사할 vector의 원소 수만큼 동적 메모리 할당
  • 객체 arrA에 복사되는 원소들의 "깊은 복사"

이 과정에서 3백만 번의 복사가 필요합니다.

 

그러나, 곰곰이 생각해 보면 실제 위의 과정은 필요 없다는 것을 알 수 있습니다.

단순히, arrA의 데이터를 임시 데이터에 이동시키고, 

arrB의 데이터를 arrA로 이동시키면 됩니다.

 

이렇게 하면 기존의 데이터를 삭제하거나, 메모리 할당, 데이터 복사 등은 전혀 필요 없습니다.

 

그래서, STL의 함수는 move semantics를 사용하여 다음과 같이 변경되었습니다.

template <typename T>
void Swap(T &lhs, T &rhs) noexcept
{
    T t = std::move(lhs);
    lhs = std::move(rhs);
    rhs = std::move(t);
}

lhs, rhs 인자가 좌측값 참조로 인식되는 것을 잊으면 안 됩니다.

따라서, 우측값 참조로 바꾸기 위해 std::move 함수를 사용합니다.

 

이제, 타입 T 객체에 이동 대입 연산자를 구현하면, 이동 방식으로 변경된 함수가 동작할 것입니다.

만약, 이동 대입 연산자가 구현되어 있지 않으면, 기존과 같은 방식으로 동작합니다.

따라서, 위의 변경은 기존의 코드를 변경하지 않으면서도, 성능의 향상을 가져옵니다.

 

상속 시, 이동 생성자 구현

파생 클래스에서 이동 생성자를 구현 시, 주의할 사항이 있습니다.

그것은 부모 생성자에 우측값 참조를 인자로 넘길 때, 그대로 넘기면 좌측값 참조로 처리된다는 점입니다.

 

아래의 예제에서는 CParent 클래스를 상속받은 CChild 클래스의 이동 생성자를 호출하고 있습니다.

의도한 대로 동작할까요?

class CParent{	// 부모 클래스

public:
    CParent(){}
    CParent(const CParent& p){
        cout << "CParent Copy Constructor\n";
    }

    CParent( CParent&& p) noexcept {
        cout << "CParent Move Constructor\n";
    }
};

class CChild : public CParent{	// 상속받은 클래스
public:
    CChild(){}
    CChild( CChild&& c) noexcept : CParent(c){	// 이동 생성자
        cout << "CChild Move Constructor\n";
    }
};

int main(){

    CChild c1;
    
    CChild c2( std::move(c1));	// c1 객체의 데이터를 이동
    
    return 0;  
}

▼출력

CParent Copy Constructor   
CChild Move Constructor

안타깝게도, 의도한 대로 동작하지 않습니다.

CParent의 이동 생성자가 호출되지 않고, 복사 생성자가 호출된 것을 볼 수 있습니다.

 

이렇게 된 이유는 이전 항목에서 말한 대로, 이동 생성자 호출 시 사용된 인자가 이름 c을  가지게 되므로 좌측값 참조로 작동하기 때문입니다.

 

그래서, CParent(c)를 호출하면 CParent의 이동 생성자를 호출하는 것이 아니라, 복사 생성자를 호출하게 됩니다.

// 우측값 참조가 이름을 갖고 있으므로 좌측값 참조로 작동
CChild( CChild&& c) noexcept : CParent(c){
    cout << "CChild Move Constructor\n";
}

따라서, 이 문제를 해결하려면, 좌측값 참조 c를 우측값 참조로 다시 한번 바꿔주면 됩니다.

// std::move 함수를 이용하여, 좌측값 참조를 우측값 참조로 변환
CChild( CChild&& c) noexcept : CParent( std::move(c)){
    cout << "CChild Move Constructor\n";
}

▼출력

CParent Move Constructor   
CChild Move Constructor

이제, 제대로 동작합니다.

 

정리

  • 우측값 참조( rvalue reference )는 우측값을 참조하는 데이터 타입입니다.
  • 우측값 참조를 사용하여, 이동 생성자( move constructor )와 이동 대입 연산자( move assignment operator )를 구현할 수 있습니다.
  • 이름이 있는 우측값 참조는 좌측값입니다.
  • 좌측값 참조를 우측값 참조로 변경하기 위해 std::move() 함수를 사용할 수 있습니다.

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유