rvalue-reference & 이동 생성자 & std::move & std::forward

원아담·2025년 9월 7일
0

공부.CPP

목록 보기
3/3
post-thumbnail

rvalue-reference

우선 우측값참조라는 rvalue-reference에 대해 알아본다.

먼저 우측값과 좌측값의 차이는 주소의 유무이다.

int lvalue = 20;

int* lvalue_addr = &lvalue;

int* rvalue_addr = &28; // error

oreilly 포스팅에서는 우측값은 "a value", 좌측값은 "an object reference"로 정의한다.

대충 내가 했던 설명과 비슷한 설명인듯하다.

object에 대한, 즉 좌측값에 대한 reference(rvalue reference)는 알다시피

int& lrefer = lvalue;

이렇게 할 수 있다. lrefer가 좌측값참조 변수이다.

반면 우측값, 즉 값 자체에 대한 참조를 우측값참조라고 하고 다음과 같이 쓴다.

int&& rrefer = 28;

&를 두개 붙여주면 된다. rrefer가 우측값참조 변수이다.

그럼 &를 두개 붙여주면 무조건 우측값참조인가? 는 또 다른 이야기이다.

위의 예시에서는 bind되어있는 값이 정말 우측값이니 우측값참조이다.

하지만 다음과 같은 예시를 보자.

int lvalue = 20; // lvalue
int& lrefer = lvalue; // lvalue-reference
int&& rrefer = 28; // rvalue-reference
auto&& urefer = lvalue; // universal-reference

rrefer와 bind되어있는 것은 우측값이므로 rrefer는 우측값참조 변수가 맞다.

하지만 auto&&로 타입 연역을 컴파일러에게 맡긴 ureferlvalue라는 좌측값에 bind되었다.

그래서 auto&&보편 참조라고 한다. (보편 참조라는 이름은 Effective Modern C++에서 나온 이름이다.)

비슷하게 템플릿 형식도 보편 참조이다.

template<typename T>
void f(T&& param);

위의 함수 f의 매개변수인 paramT&& 타입인데,

이는 우측값이 bind될 수도 있고 좌측값이 bind될 수도 있다.

그래서 이것도 보편 참조이다.


이동 생성자

아빠, 엄마, 형제가 있는 Person이라는 클래스로 예를 들어본다.

우선 복사생성자는 이렇게 생겼다.

class Person
{
public:
    Person(const Person& p)
    {
        _age = p._age;
        _name = p._name;

        if(p._dad)
            _dad = new Person(*p._dad);

        if(p._mom)
            _mom = new Person(*p._mom);

        for (Person* sibling : p._siblings)
        {
            _siblings.emplace_back(sibling);
        }
    }

private:
    int age;
    std::string name;
    Person* dad;
    Person* mom;
    std::vector<Person*> siblings;
};

자신 타입의 참조를 받아서 DeepCopy한다.

하지만 이동 생성자의 구현은 그렇지않다.

웬만하면 전부 ShallowCopy를 한다.

    Person(Person&& p) noexcept
    {
        std::cout << "Person(Person&& p)" << std::endl;

        _age = p._age;
        _name = std::move(p._name);

        _dad = p._dad;
        p._dad = nullptr;

        _mom = p._mom;
        p._mom = nullptr;

        for (Person* sibling : p._siblings)
        {
            _siblings.emplace_back(sibling);
        }

        p._siblings.clear();
    }

우선 std::move에 대해서는 무시하도록 한다. (뒤에서 설명)

보면 특이한 것이 있는데

복사를 당하는 객체의 동적할당한 포인터는 nullptr로 만들어주는 것이다.

복사가 아닌 "이동"을 해주는 것이다.

Person me(28, "wondong");
Person mom(51, "yj");
Person dad(59, "ty");
Person brother(25, "dj");

me.SetDad(&dad);
me.SetMom(&mom);
me.AddSibling(&brother);
brother.SetDad(&dad);
brother.SetMom(&mom);
brother.AddSibling(&me);

위처럼 세팅하고

Person clone(std::move(me));

위의 코드를 실행하면 이동 생성자가 실행되는데

이동 생성자 결과

싹다 털린 것을 볼 수 있다.

그에 비해 복사 생성자는

Person clone(me);

복사 생성자 결과

우리가 생각하는 "복사"의 결과임을 볼 수 있다.


noexcept

눈치챘을지 모르겠지만 이동 생성자에는 noexcept를 붙여야 한다.

noexcept는 컴파일러에게 "이 함수는 예외를 던지지 않는다"는 것을 알려주는 것이다.

이동 생성자 실행중 예외가 발생할 경우 객체가 온전하지않은 상태에서 이동을 중단하기때문에 필요하다.

noexcept를 안 붙여서 Visual Studio가 화남

Visual Studio 2019는 noexcept를 이동 생성자에 붙이지 않으면 화를 낸다.


이동 대입연산자

이동 대입연산자도 이동 생성자와 비슷한 구현을 따르면 된다.

    Person& operator=(Person&& p) noexcept
    {
        std::cout << "operator=(Person&& p)" << std::endl;

        _age = p._age;
        _name = std::move(p._name);

        _dad = p._dad;
        p._dad = nullptr;

        _mom = p._mom;
        p._mom = nullptr;

        for (Person* sibling : p._siblings)
        {
            _siblings.emplace_back(sibling);
        }

        p._siblings.clear();

        return *this;
    }

For What...?

복사 생성자까진 참조를 이해했으면 이해하기 쉽고 쓰임새도 예측하기 쉽다.

하지만 이동 생성자는 우측값참조부터 시작해서 어디에 쓰는 물건인고...하기 쉬운데.

C++에는 복사가 많은 상황에서 일어난다.

그 중 복사를 하고 즉시 소멸하는 경우에서 이는 성능 측면에서 이점이 있다.



std::move

std::move는 인자로 들어온 객체를 우측값으로 변환해준다.

그래서 move라는 단어의 뜻과는 달리 std::static_cast<T&&>() 와 비슷하다.

그래서 위에서 봤던

_name = std::move(p._name);

는 std::string 타입을 std::string의 우측값으로 변환해주는 것이다.

그러면 std::string에서 구현되어있는 이동 관련 연산자/생성자가 호출된다.

std::forward

std::forward는 템플릿에서 매개변수의 원래 타입(lvalue/rvalue)을 보존하면서 전달한다.

#include <utility>

// 문제 상황: 매개변수가 항상 lvalue가 됨
template<typename T>
void wrapper_bad(T&& param) {
    someFunction(param); // param은 항상 lvalue로 전달됨
}

// 해결: Perfect Forwarding
template<typename T>
void wrapper_good(T&& param) {
    someFunction(std::forward<T>(param)); // 원래 타입 보존
}

void test() {
    MyString str("test");
    
    wrapper_good(str);              // lvalue로 전달
    wrapper_good(std::move(str));   // rvalue로 전달
    wrapper_good(MyString("temp")); // rvalue로 전달
}

위에서 설명했던 Universal Reference
템플릿에서 T&&는 상황에 따라 lvalue reference 또는 rvalue reference가 되는데,
호출부에서 뭐로 전달했건 전달한 타입으로 전달해달라는 의미가 std::forward.




매번 이 부분은 공부하면서는 이해는 가지만 실무에서 써먹기가 어렵다... ㅠ

참조
C++ Primer | Stanley Lippman
Effective Modern C++ | Scott Meyers
Claude AI

profile
게임 만드는 사람

0개의 댓글