c++ 11에서는 move semantics와 관련된 std::move와 std::forward가 추가되었습니다.
두 함수를 설명드리기 전에 기본적인 개념인 보편 참조와 참조 축약에 대해서 설명드립니다.
해당 개념을 이해하셔야 std::move와 std::forward의 내부 동작 방식을 이해하실 수 있습니다.
rvalue reference vs universal reference(보편 참조)
c++에서 참조는 &와 &&으로 표현됩니다. &는 lvalue 참조를 의미합니다.
&&은 문맥에 따라서 rvalue 참조 또는 universal(보편) 참조를 의미합니다. (마치 한글의 동음이의어와 같습니다.)
rvalue 참조는 쉽게 이해가 되지만 universal(보편) 참조가 의미하는 내용은 무엇일까요?
universal(보편) 참조는 rvalue 참조 또는 lvalue 참조 둘 다 될 수 있다는 것을 의미합니다.
문맥에 따라서 유동적으로 변환 될 수 있는 참조입니다.
// rvalue 참조
void f(Person&& obj)
// rvalue 참조
Person&& p1 = Person("ahn", 1985);
// universal 참조
// var1는 auto로 명시적이지 않습니다.
auto && var1 = var2
// rvalue 참조
// param은 std::vector로 명시적이고 vector 내부 객체가 T만 연역
template<typename T>
void tf(std::vector<T>&& param);
// universal 참조
// param은 T로 명시적이지 않습니다.
template<typename T>
void tf2(T&& param);
위의 예제를 보면 문맥에 따라서 rvalue 참조인지 universal(보편) 참조인지 구분되는 것을 확인 할 수 있습니다.
위의 예제를 보면 알 수 있듯이 대상 자체가 명시적이지 않으며 형식 연역이 발생하면
universal(보편) 참조가 되는 것을 확인 할 수 있습니다.
일반적으로 rvalue 참조에는 std::move를 사용하고 universal(보편) 참조는 std::forward를 통해서 전달합니다.
참조 축약
참조 축약이란 universal 참조에서 참조의 참조의 형태의 데이터형이 만들어 질 수 있는데
이 때 컴파일러에 의해서 참조의 참조를 lvalue 참조나 rvalue 참조로 변환하는 것을 말합니다.
template<typename T>
void func(T&& param) // param은 보편 참조
Person p1("ahn", 1985);
func(p1); // lvalue 호출
// fun(p1)은 아래와 같은 형식으로 연역됩니다.
// T = Person&
=> void func(Person& && param) => void func(Person & param)
func(std::move(p1)) // rvalue 호출
// fun(std::move(p1))은 아래와 같은 형식으로 연역됩니다.
// T = Person
=> void func(Person && param)
위의 예제를 보면 보편 참조에 Person lvalue 참조를 전달하면 T는 Person &으로 연역됩니다.
파라미터 타입은 Person & && param의 형식이 됩니다. 실제로는 참조의 참조는 존재하지 않기 때문에 컴파일러는
Person & && param => Person & param로 변환합니다.
보편 참조에 Person rvalue 참조를 전달하면 T는 Person으로 연역되서 파라미터는 Person && param로 지정됩니다.
참조 축약 변환 | 첫번째 참조 & | 첫번째 참조 && |
두번째 참조 & | lvalue 참조 | lvalue 참조 |
두번째 참조 && | lvalue 참조 | rvalue 참조 |
컴파일러의 참조 축약의 변환 규칙은 참조의 참조중에 하나라도 lvalue 참조라면 lvalue 참조로 변환 합니다.
그렇지 않다면 rvalue 참조로 변환 합니다.
예제로 사용할 Person 클래스
using namespace std;
struct Person
{
string name;
int* year = nullptr;
Person()
{}
Person(string p_name, const int p_year) : name(move(p_name)), year(new int(p_year))
{
cout << "constructed" << endl;
}
Person(const Person& other) noexcept :
name(move(other.name)), year(new int(*other.year))
{
cout << "copy constructed" << endl;
}
Person(Person&& other) noexcept :
name(move(other.name)), year(nullptr)
{
// Rvalue의 힙에서 할당된 year를 이동시킵니다.
year = other.year;
// Rvalue의 year를 nullptr 초기화 시킵니다.
other.year = nullptr;
cout << "move constructed" << endl;
}
Person& operator=(const Person& other) noexcept
{
if (this != &other)
{
this->name = other.name;
this->year = new int(*other.year);
}
cout << "copy Assignment operator" << endl;
return *this;
}
Person& operator=(Person&& other) noexcept
{
if (this != &other)
{
this->name = std::move(other.name);
if (this->year) delete this->year;
this->year = other.year;
other.year = nullptr;
}
cout << "move Assignment operator" << endl;
return *this;
}
virtual ~Person()
{
if (nullptr != year)
{
delete year;
}
cout << "destructed " << endl;
}
};
std::move
std::move는 전달된 파라미터를 강제로 rvalue 참조로 캐스팅하여 리턴하는 함수입니다.
내부를 살펴보면 간단히 구현되어 있습니다. 캐스팅만 수행하고 어떠한 작업도 하지 않습니다.
template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;
// FUNCTION TEMPLATE move
template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
예제를 통해서 std::move 사용법을 알아 봅니다.
Person p1("ahn", 1985);
// p1을 std::move를 통해서 이동연산이 가능하도록 캐스팅합니다.
Person p2= std::move(p1);
예제 코드를 보면 p1이라는 lvalue를 std::move를 통해서 rvalue로 변환 후 이동 생성자를 통해서 p2를 생성합니다.
std::move를 보면 모든 값을 rvalue로 캐스팅 할 수 있을 것 같지만 rvalue로 변환될 수 없는 대상도 있습니다.
const Person p1("ahn", 1985);
// const Person인 p1을 std::move를 통해서 캐스팅합니다.
// 하지만 const Person&&으로 변환되고 const가 붙어서 이동 생성자가 채택되지 못하고
// p2는 복사 생성자로 생성됩니다.
Person p2= std::move(p1);
p1의 데이터형을 const Person으로 변환해서 std::move를 실행하면 다른 결과를 확인 할 수 있습니다.
p1은 std::move를 통해서 const Person &&로 캐스팅 됩니다. Person 내부에서 생성자를 선택할 때 const 때문에
이동 생성자가 채택되지 못하고 복사 생성자를 채택되고 p2는 복사생성자를 통해서 생성됩니다.
이것은 코드를 작성자의 의도와는 다르게 동작할 가능성이 높습니다.(std::move를 사용한다는건 이동 연산 선호)
이동을 수행 할 객체는 const로 선언하지 말아야 합니다.
std::move의 내부동작
// _Arg가 lvalue 참조인 경우
template <class Person &>
_NODISCARD constexpr remove_reference_t<Person &>&& move(Person & && _Arg) noexcept {
return static_cast<remove_reference_t<Person &>&&>(_Arg);
}
// _Arg가 rvalue 참조인 경우
template <class Person>
_NODISCARD constexpr remove_reference_t<Person>&& move(Person&& _Arg) noexcept {
return static_cast<remove_reference_t<Person>&&>(_Arg);
}
// 두 형식 모두 아래와 같이 해석됩니다.
_NODISCARD constexpr Person && move(Person&& _Arg) noexcept {
return static_cast<Person &&>(_Arg);
}
내부적으로는 위와 같이 호출됩니다. 보편 참조이기 때문에 lvalue나 rvalue로 연역될 수 있는 데
remove_reference_t를 사용하여 _Ty타입의 참조을 제거 하여 두가지 연역 모두 동일한 형식으로 변환 되도록
구현 되어 있는 것을 확인 할 수 있습니다.
std::forward
이 함수도 std::move처럼 캐스팅만 수행합니다. std::move와는 다르게 조건에 따라서 다른 값을 리턴합니다.
입력 값이 lvalue 참조가 아니라면 rvalue 참조(이동연산지원)를 리턴하고
lvalue 참조라면 수정하지 않고 그대로 리턴합니다.
// FUNCTION TEMPLATE forward
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{ // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept
{ // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
return static_cast<_Ty&&>(_Arg);
}
std::forward가 가장 많이 쓰이는 곳은 보편 참조 매개변수로 받아서 처리하는 로직입니다.
// lvalue를 캐치하는 함수
void Catch(Person& p, const char* name)
{
cout << name << "lvalue catch" << endl;
}
// rvalue를 캐치하는 함수
void Catch(Person&& p, const char * name)
{
cout << name << "rvalue catch" << endl;
}
// 전달받은 obj를 std::forward를 통해서 catch 함수로 전달합니다.
template<typename T>
void ForwardingObj(T&& obj, const char* name)
{
Catch(std::forward<T>(obj), name);
}
int _tmain(int argc, _TCHAR* argv[])
{
Person p1("ahn", 1985);
ForwardingObj(p1, "p1\t\t=\t");
ForwardingObj(std::move(p1), "std::move(p1)\t=\t");
return 0;
}
예제 코드를 보면 ForwardingObj라는 함수는 보편 참조인 obj를 받아서 Catch함수를 수행합니다.
std::forward을 통해서 obj의 파라미터 입력값이 lvalue 참조면 수정 없이 Catch를 수행하고 rvalue 참조면 rvalue
참조로 캐스팅해서 이동연산을 수행합니다.
여기서 주의 할 점은 rvalue 참조면 그냥 넘겨도 동작 해야 하지 않냐는 것이다.
이것이 move semantics를 처음 접할 때 가장 혼란스러운 부분인데 잘 기억 해야 합니다.
매개변수가 rvalue 참조인 경우에도 매개변수 자체는 lvalue입니다. 매개변수는 항상 lvalue입니다.
왜냐하면 매개변수는 주소를 가질 수 있습니다.
그럼 rvalue 참조를 전달하기 위한 방법이 필요한데 그 방법으로 std::forward 함수를 사용합니다.
매개변수의 값이 rvalue 참조일 경우 이동 연산이 지원되는 rvalue 참조로 캐스팅하는 역활을 합니다.
std::forward의 내부동작
template<typename T>
void func(T&& param) // param은 보편 참조
{
return std::forward<T>(param);
}
/////////////////////////////////////////////////////////////////////////////
// param이 lvalue Person이라면
template <class Person &>
_NODISCARD constexpr Person & && forward(
remove_reference_t<Person &>& _Arg) noexcept {
return static_cast<Person & &&>(_Arg);
}
// 참조 축약으로 인해서 아래와 같이 변경
// lvalue 참조로 캐스팅해서 전달
template <class Person &>
_NODISCARD constexpr Person & forward(
remove_reference_t<Person &>& _Arg) noexcept {
return static_cast<Person &>(_Arg);
}
//////////////////////////////////////////////////////////////////////////////
// param이 rvalue Person이라면
// rvalue 참조로 캐스팅해서 전달
template <class Person>
_NODISCARD constexpr Person && forward(
remove_reference_t<Person>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
return static_cast<Person&&>(_Arg);
}
참조 축약을 통해서 forward 호출 구문을 변경해보면 위와 같이 변형되서 호출 되기 때문에 std::forward를 통해서
조건부 캐스팅이 가능해 집니다.
'c++ > modern c++' 카테고리의 다른 글
[c++] constexpr 키워드 (0) | 2020.03.19 |
---|---|
[c++] auto와 decltype 키워드 (0) | 2020.03.15 |
[c++] Move semantics (0) | 2020.03.07 |
[c++] 람다(Lambda) 표현식 (0) | 2020.03.04 |
[c++] weak_ptr (1) | 2020.03.02 |