우측값 참조란?
우측값 참조는 이름처럼 우측값을 참조하는 자료형입니다. r-value reference라고도 합니다.
lvalue만을 참조할 수 있던 일반적인 참조형과는 다릅니다.
일반 참조형은 &기호를 쓰지만, 우측값 참조는 &&를 씁니다.
아래 코드를 봐주세요.
int main()
{
int i = 10;
int& iRef = i; //lvalue만 참조 가능
//int& iRef = 10; //컴파일 에러 : 비const 참조에 대한 초기값은 lvalue여야 함
int&& rRef = 11;// rvalue만 참조 가능
//int&& rRef = i;//컴파일 에러 : rvalue 참조를 lvalue에 바인딩할 수 없습니다.
const int& cRef = i; //lvalue, rvalue 모두 참조 가능
const int& cRef2 = 12;
}
위 코드처럼 rvalue 참조형 rRef 변수는 rvalue만 참조할 수 있습니다.
그리고 rvalue reference형의 특징을 알 필요가 있습니다.
아래 코드는 직전의 예에서 몇 가지 함수를 추가하고 호출하고 있습니다.
void Func(int& _src)
{
cout << "&\n";
}
void Func(const int& _src)
{
cout << "const &\n";
}
void Func(int&& _src)
{
cout << "&&\n";
}
int main()
{
int i = 10;
int& iRef = i; //lvalue만 참조 가능
//int& iRef = 10; //컴파일 에러 : 비const 참조에 대한 초기값은 lvalue여야 함
int&& rRef = 11;// rvalue만 참조 가능
//int&& rRef = i;//컴파일 에러 : rvalue 참조를 lvalue에 바인딩할 수 없습니다.
const int& cRef = i; //lvalue, rvalue 모두 참조 가능
const int& cRef2 = 12;
Func(i); //lvaue
Func(iRef); // lvalue
Func(rRef); // lvalue
Func(cRef); // const lvalue
Func(13); // rvalue
}
위 코드의 실행 결과는 아래와 같습니다.
위 결과로 알 수 있는 것은, 2가지 입니다.
- rvalue reference형은 lvaue다.
- rvalue를 인자로 넘겨주었을 때, const ref 매개변수 보다 rvalue ref 매개변수를 선호한다.
1번은 Func(rRef) 문장의 결과가 & 인 것을 보고 알 수 있습니다.
2번은 Func(13) 문장의 결과가 &&인 것을 보고 알 수 있습니다. 만약 Func(int&& _src) 버전의 오버로딩이 없었다면 대신Func(const int& _src)가 호출될 것입니다.
이동?
rvalue reference는 보통 이동 연산을 위해 쓰입니다. 여기서 이동이란, 한 객체의 소유권을 다른 포인터로 이전해주는 것입니다. 복사와 대조되는 개념이라고 생각하시면 좋습니다.
class A
{
public:
A()
{
cout << "기본생성\n";
}
A(const A& rhs)//복사 생성
{
cout << "const A& 생성\n";
}
A& operator=(const A& rhs) // 복사 대입
{
cout << "const A& 대입\n";
return *this;
}
A(A& rhs)//복사 생성
{
m_iSize = rhs.m_iSize;
m_iArr = new int[rhs.m_iSize];
memcpy(m_iArr, rhs.m_iArr, sizeof(int) * rhs.m_iSize);
cout << "A& 생성\n";
}
A(A&& rhs) //이동 생성자
{
m_iArr = rhs.m_iArr;
m_iSize = rhs.m_iSize;
rhs.m_iArr = nullptr;
cout << "A&& 생성\n";
}
A& operator=(A& rhs) // 복사 대입
{
delete[] m_iArr;
m_iSize = rhs.m_iSize;
m_iArr = new int[rhs.m_iSize];
memcpy(m_iArr, rhs.m_iArr, sizeof(int) * rhs.m_iSize);
cout << "A& 대입\n";
return *this;
}
A& operator=(A&& rhs) // 이동 대입
{
delete[] m_iArr;
m_iArr = rhs.m_iArr;
m_iSize = rhs.m_iSize;
rhs.m_iArr = nullptr;
cout << "A&& 대입\n";
return *this;
}
private:
int* m_iArr;
int m_iSize;
};
위 코드의 A라는 클래스는 복사 생성자, 이동 생성자, 복사 대입 연산자, 이동 대입 연산자를 가지고 있습니다. (매개변수가 const ref인 것들은 귀찮아서 내부는 생략시켰습니다...)
복사 생성자, 복사 대입 연산자 에서는 m_iArr라는 동적 배열에 메모리를 새로 할당해서 rhs의 내용을 복사해주고 있습니다.
이동 생성자, 이동 대입 연산자 에서는 메모리를 새로 할당하지 않고, rhs가 가지고 있던 포인터를 똑같이 참조하게 한 후, rhs의 포인터를 nullptr로 만들어 소유권을 가져왔습니다. 즉, this->m_iArr만 객체를 가리키게 만들었습니다.
당연하게도 메모리를 새로 할당하고 데이터를 모두 복사하는 복사 작업보다 메모리 할당 없이 포인터만 복사해주는 이동 작업이 훨씬 빠르게 동작합니다. 대신 rhs에 있던 정보는 사라지겠죠.
이것이 이동 연산입니다.
참고로 const & 버전의 오버로딩은 이동연산을 구현할 수 없습니다. rhs가 const 형이기 때문에 rhs.m_iArr에 nullptr을 대입해줄 수가 없기 때문입니다.
그럼 이 A클래스를 직접 생성해 봅시다.
int main()
{
A a; //기본 생성
A b = a; // 복사 생성
A c = move(a); // 이동 생성
}
아래는 실행 결과입니다.
std::move
근데 위에서 이동 생성자를 호출할 때, move(a)라는 것을 사용했습니다.
move 함수는 std namespace에 있는 함수로, rvalue reference 형으로 캐스팅해주는 함수입니다.
move 함수를 썼다고 해서 바로 이동 연산이 되는 것은 아닙니다. 이 move 함수를 사용해 이동 생성자, 이동 대입 연산자 등 rvalue reference를 받는 오버로딩 함수를 호출시켜줄 수 있을 뿐입니다.
내부 구현은 다음과 같이 static_cast를 통해 캐스팅해주고 있네요.
_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
rvalue reference의 필요성
rvalue reference는 이동연산에 주로 쓰인다고 했습니다. 이동 연산을 제대로 구현하기 위해서는 rvalue reference가 필요해요.
위의 A클래스의 예에서, 이동 연산은 &&를 사용한 rvalue reference를 매개변수로 받았고,
복사 연산은 &를 사용한 lvalue reference 그리고 const lvalue reference를 매개변수로 받았습니다.
그런데 사실, 꼭 rvalue ref로 이동을 구현하지 않아도 됩니다. lvalue ref로도 이동연산을 구현할 수 있습니다.
A(A& rhs)//lvalue ref를 받는 이동 생성자
{
m_iArr = rhs.m_iArr;
m_iSize = rhs.m_iSize;
rhs.m_iArr = nullptr;
cout << "A& 생성\n";
}
이렇게 해도 이동하는 데에 문제는 없습니다. 그렇다면 왜 ravlaue reference(&&)가 필요할까요?
우리가 하는 복사, 이동은 아래 4가지 경우 중 하나일 것입니다.
- lvaue를 받아서 복사
- lavue를 받아서 이동
- const lvaue를 받아서 복사
- rvalue를 받아서 이동
rvalue를 받아서 복사는 없습니다. 왜냐하면 rvalue는 임시 객체이고, 복사해서 원본을 지켜낸다 한들 이득이 없기 때문입니다. 또한, const lvaue를 받아서 이동도 없습니다. 위에서 언급했듯 const라서 nullptr 대입이 불가능하기 때문입니다.
우리는 위 4가지 경우 모두 사용하고 싶습니다. 그런데 만약 rvalue ref가 없는 상태에서 생성자를 만들어준다고 했을 때, 어떻게 위 4가지 경우 모두 처리할 수 있을지 고민해 봅시다.
1번의 경우, A(A& rhs){} 가 처리해줄 수 있습니다.
2번의 경우, A(A& rhs){} 가 처리해줄 수 있을까요? 아뇨, 1번을 위해서 이미 복사 연산을 하도록 구현되어 있습니다.
3번의 경우, A(const A& rhs){}가 처리해줄 수 있습니다.
4번의 경우, 어떻게 하죠? A(const A& rhs){}가 rvalue를 받을 수는 있지만, 이동연산은 불가능합니다.
이렇듯 rvalue reference가 없으면 구현할 수 없는 부분이 있습니다.
2, 4번은 ravlue reference와 move 함수를 통해 해결할 수 있습니다.
2번은 위에서 보였던 A c = move(a); 와 같이 해결할 수 있습니다.
4번은 A(A&& rhs){} 오버로딩을 통해 해결할 수 있습니다.
'C++' 카테고리의 다른 글
RTTI (0) | 2024.04.27 |
---|---|
이름 숨김 (0) | 2024.04.15 |
가상함수 (2) | 2024.04.12 |
헷갈리는 C++ 질문들 (0) | 2024.04.12 |
Casting (Static, Dynamic, Reinterpret ) (0) | 2023.03.28 |