[C++]

Modern C++ #8 오른값 참조(rvalue reference)와 std::move

럭키🍀 2024. 10. 22. 14:48

rvalue reference

오른값 참조는 실제로 많이 사용하진 않지만 C++11 에서 가장 큰 변화라고 할 수 있다.

rvalue의 사용으로 이전 C++과는 엄청난 속도차이를 불러일으켰다.

 

왼값 (lvalue) vs. 오른값(rvalue)

lvalue : 단일 식을 넘어서 계속 지속되는 개체

rvalue : lvalue가 아닌 나머지(임시값, 열거형, 람다, i++)

 

왜 왼값과 오른값이라는 표현을 사용하는지.

int a = 3;

a = 4; 

cout<<a<<endl;

test(a);

이렇게 처음에 사용한 '왼쪽'에 있던 a는 계속 사용할 수 있다.

반면 3 = a; 이렇게 사용할 수 없다.('식이 사용할 수 있는 왼값이여야합니다.' 라고 오류가 난다.)

또 a++ = 5; 이렇게도 사용할 수 없다.

이런 3이나 a++같은 임시적으로만 사용할 수 있는 값을 통상적으로 오른값이라고 한다. 

 

그런데 기본적인 상수가 아니라 

hp가 100인 Knight클래스를 정의한다음 k1이라는 객체를 만들었다고 하자.

class Knight
{
public:
    int _hp = 100;
public:
    void PrintInfo() { cout<<_hp<<endl; }
    void PrintInfoConst() const { cout<<_hp<<endl; }
}

void TestKnight_Copy(Knight knight) {}
void TestKnight_LValueRef(Knight& knight) {}
void TestKnight_ConstLValueRef(const Knight& knight) 
{
    //knight.PrintInfo();
    knight.PrintInfoConst(); // const로 받은 인자의 const함수만 사용할수 있다. 
}

int main()
{
    Knight k1;
    
    TestKnight_Copy(k1); // 만약 Knight 클래스의 크기가 크면 복사하는 방식이므로 좋지 않다.
    
    TesetKnight_LValueRef(k1); // k1의 주소값을 넘겨줘 불필요한 복사도 없고 원본을 넘길수 있다.
    
    TesetKnight_LValueRef(Knight()); 
    // 임시객체를 넘기려고 하면 const가 붙지않은 값은 lvalue여야한다고 오류가 뜬다.
    // 즉, 단일식을 넘어서 계속 지속되는 개체가 아니라 오류가 나는데
    
    TestKnight_ConstLValueRef(Knight());
    // 이렇게 인자를 const로 받으면 임시객체를 넘기는게 된다.
    // const가 붙지 않으면 함수 내에서 인자로 넘어온 변수의 값을 바꿀 수 있는데
    // 사실상 임시개체가 들어오면 사라지므로 의미 없는 계산이다.
    // const는 읽기용으로 사용할 것이라 예측하기 때문에 넘어간다.

    return 0;
}

 

    
    이전까지는 위와 같았는데 C++11 이후엔 오른값을 넘기는 방법이 생겼다.

void TestKnight_RValueRef(Knight&& knight) {}
//&&를 두 개 붙여서 오른값을 참조로 받을 수 있다.

int main()
{
  Knight k1;
  
  //TestKnight_RValueRef(k1); // lvalue는 받을 수 없어 오류가 난다.
  TestKnight_RValueRef(Knight()); 이렇게 다시 오른값을 보내면 잘 실행된다.
  
  return 0;
}

디스어셈블리를 보면 TestKnight_Copy 는 _hp값만 복사되어 k1이 아닌 다른 객체가 전달되고

 LValueRef, ConstLValueRef, ValueRef 모두  비슷하게 원본 (Knight()을 넘겨주는곳은 스택에 잠깐 객체를 만든다음) 그 주소를 꺼내서 함수에 넘겨주는 방식이다.

 

이렇게 오른값을 넘겨서 함수내에서 그 인자를 수정하는게 의미가 있나 싶지만 꼭 임시값만 넣을 수 있는 것은 아니다.

     TestKnight_RValueRef(static_cast<Knight&&>(k1);


k1은 실질적으로 존재하지만 RValue로 캐스팅해서 보내면 동작하므로
TestKnight_RValueRef 안에서 k1의 원본 값들을 수정할수 있다.
-> 그럼 lvalue를 인자로 받는 함수를 쓰면 되지 굳이 이걸 쓰는 이유는..?

 

어셈블리단에서는 LValue를 받는것과 RValue를 받은 것이 큰 의미가 차이가 없지만 C++입장에서 보면 크게 다르다.

함수들의 인자들을 비교해보면

 

Knight& knight 는 원본을 넘겨주니 수정이 가능하고 마음대로 다룰 수 있다.

const Knight& knight 는 원본을 넘겨주나 수정해선 안되고 읽는 것은 가능하다.

Knight&& knight 는 원본 객체를 넘겨주니 자유롭게 읽고 쓰기가 가능하고 심지어 인자로 넘긴 후 원본은 더 이상 활용하지 않을것이니 마음대로 다룰 수 있다. 즉, 이동대상이라는 의미이다.

 

원본을 유지하지 않아도 되면 생기는 장점을 생각해보면

만약 Knight클래스의 크기가 엄청 큰 클래스라고 가정해보자.

class Pet
{
};

class Knight
{
public:
    Pet* _pet = nullptr;
    int  _hp = 100;
    
public:
    Knight() {cout<<"Knight()"<<endl;}
    // 복사 생성자.
    Knight(const Knight& knight)
    {
        cout<<"const Knight&"<<endl;
    }
    
    // 이동 생성자. (오른값을 받는다.받은 값을 보내고 나서 더 이상 활용하지 않는다.)
    Knight(Knight&& knight)
    {
        cout<<"Knight&& knight)"<<endl;
    }
    
    ~Knight()
    {
        if(_pet)
            delete _pet;
    }
    
public:
    // 복사 대입 연산자
    void operator=(const Knight& knight)
    {
        cout<<"operator=(const Knight&)"<<endl;
        _hp = knight._hp;
        //_pet = knight._pet; // 얕은 복사의 문제점. knight의 _pet을 나도 공유하게 된다.
        if(knight._pet != null)
            _pet = new Pet(*knight._pet); // 깊은 복사. 그런데 새로 만들어야하므로 비싸다.
    }
    
    // 이동 대입 연산자 - 받은 값을 더이상 사용하지 않으므로 훼손해도 된다.
    void operator=(Knight&& knight)
    {
        _hp = knight._hp;
        _pet = knight._pet; // 얕은 복사를 사용해도 어차피 knight가 없어질 것이므로 바로 _pet을 사용해도 된다..!
        knight._pet = nullptr;
    }
};


int main()
{
    /// ....
    
    Knight k2;
    k2._pet = new Pet();
    k2._hp = 1000;
    
    Knight k3;
    k3 = static_cast<Knight&&>(k2); // k2가 오른값이므로 이동대입연산자가 호출된다.
    // k2는 더이상 사용하지 않는다는 정보를 같이 주는 것이다.
    // k2의 값을 k3가 뺏어오니깐 k2의 값들을 '이동'시키는 것이다.
    // 참고로 static_cast로 오른값으로 타입변환을 하기도 하지만 일반적으로
    k3 = std::move(k2); // 이렇게 사용한다. move가 오른값 참조로 캐스팅한것과 마찬가지로 내부에서 동작한다.
    
    return 0;
    
}

 

복사생성자나 복사대입 연산자에서 깊은 복사를 사용하면 새로운 객체를 만들어야하므로 더 느리고 무거울 수 있는데

이동생성자나 이동대입연산자에서는 전달받은 객체를 더 이상 사용하지 않을 것을 아므로 얕은 복사만으로 이전 정보들을 빠르게 가져와 쓸 수 있다.

 

std::move()를 사용해 오른값 참조로 캐스팅을 주로 하는데 처음에 만들 때 rvalue_cast라는 이름으로 쓸까도 후보에 있었다곤 한다.  코어에서는 임시값을 사용했다가 쓰는 일이 빈번해서 C++11 이후에는 성능차이가 크게 생겼다.

 

또 다른 사용 예시를 보자면

Knight* k; 를 많이 쓰다보면 누가 k를 관리하는지 확인하기 쉽지 않다보니 딱 하나만 사용한다고 하면 unique_ptr을 사용할 수 있다. 프로젝트에 딱 하나만 존재해야하는 포인터

std:: unique_ptr<Knight> uptr = std::make_unique<Knight>(); 를 만든 다음에

//std:: unique_ptr<Knight> uptr2 = uptr; // 하면 삭제된 함수라고 오류가 난다. unique_ptr 클래스를 설계할 때 복사부분을 다 제거한 것이다. 

만약 이 시점에서 uptr을 더 이상 사용하지 않을 심산으로 uptr2에게 uptr을 관리해달라고 넘기고 싶어지면

unique_ptr의 소유권을 넘긴다는 뜻으로 오른값으로 캐스팅하는 std::move를 사용해

std:: unique_ptr<Knight> uptr2 = std::move(uptr); 

이렇게도 오른값 참조를 사용할 수 있다.

 

즉, 오른값 참조는 원본값을 더 이상 사용할 필요가 없다거나 unique_ptr처럼 권리를 넘기고 싶을 때 사용하면 된다. 이로써 복사하는 값이 들지 않고 포인터를  이동으로 넘겨줌으로써 속도에 엄청난 이점을 가져왔다.