Advanced C++

[Modern C++] (5-1) 오른값 참조, 이동 의미론, 완벽전달

로파이 2021. 4. 12. 13:32

이동 의미론(Move Semantics)과 완벽 전달(Perfect Forwarding)

 

Move Semantics: 비싼 복사 연산을 덜 비싼 이동 연산으로 대체할 수 있다.

Perfect Forwarding: 임의의 인수들을 받아서 다른 함수로 전달하는 함수를 작성할 때, 정확히 같은 타입의 인수로 전달할 수 있다.

 

1. std::move와 std::forward를 숙지한다.

std::move는 주어진 인수를 무조건 오른값으로 캐스팅하고, std::forward는 특정 조건이 만족될 때에만 그런 캐스팅을 수행한다.

 

- std::move의 구현

namespace std
{
	// C++ 11
	template<typename T>
	typename remove_reference<T>::type&& move(T&& param)
	{
		using ReturnType = typename remove_reference<T>::type&&;
		return static_cast<ReturnType>(param);
	}

	// C++ 14
	template<typename T>
	decltype(auto) move(T&& param)
	{
		using ReturnType = remove_reference_t<T>&&;
		return static_cast<ReturnType>(param);
	}
}

1. move 함수는 && 참조를 받는다.

2. 참조를 제거한 타입 remove_reference<T>::type으로 부터 r-value 참조자 && 타입으로 캐스팅한다.

3. 반환 타입은 무조건 오른값이다.

 

std::move는 캐스팅을 수행할 뿐, 이동(move)는 수행하지 않는다.

 

- const 매개 변수를 move할 때

class Annotation
{
private:
	std::string value;
public:
	explicit Annotation(const std::string text)
		:
		value(std::move(text))
	{}
};

std::move(text)의 결과는 const std::string &&, 즉 const 오른값 참조가 된다.

class string
{
public:
	string(const string& rhs); // const 오른값은 const 왼값 참조에 묶인다.
	string(string&& rhs);
};

const std::string&& 형식의 rhs는 함수의 오버로딩 과정에서 가장 맞는 함수를 선택하게 된다. 이 과정에서 const 매개변수를 받는 복사 생성자가 선택되는데, const 오른값 참조&&는 const 왼값 참조&에 묶일 수 있기 때문이다. 때문에 const 매개변수로 받는 생성자의 move는 이동 생성자가 오버로딩되지 않는다.

 

이동을 지원할 객체에 대해 const 매개 변수로 받지 말아야하며 std::move는 r-value 참조에 묶일 수 있도록만 해주고 실제 이동 자격을 갖게되는 것은 아니다.

 

- std::forward는 조건부 캐스팅

std::move는 주어진 인수를 무조건 오른값으로 캐스팅하지만, std::forward는 특정 조건이 만족될 때에만 캐스팅한다.

void process(const Widget& lvalArg)
{
	printf("lvalue\n");
}
void process(Widget&& rvalArg)
{
	printf("rvalue\n");
}

template<typename T>
void logAndProcess(T&& param)
{
	auto now = std::chrono::system_clock::now();

	makeLogEntry("Calling 'process'", now);
	process(std::forward<T>(param));
}
// 항목 23
Widget w;

logAndProcess(w); // 왼값 호출
logAndProcess(std::move(w)); // 오른값 호출

자신의 내부에서 logAndProcess는 주어진 param을 함수 process에 "전달"한다. param이 오른값이라면 오른값으로 전달하고 왼값이라면 왼값으로 전달한다. 따라서 실제 param의 타입에 따라 다른 함수가 오버로딩된다.

 

std::forward는 템플릿 인수 T를 참고하여 오른값 인수에 대해서만 오른값으로 캐스팅한다. 

class Widget
{
public:
	Widget() {}
	Widget(Widget&& rhs)
		:
		s(std::move(rhs.s))
	{
		++moveCtorCalls;
	}
private:
	static std::size_t moveCtorCalls;
	std::string s;
};
class Widget
{
public:
	Widget() {}
	Widget(Widget&& rhs)
		:
		s(std::forward<std::string>(rhs.s)) // 관례적으로 안쓰는 표현
	{
		++moveCtorCalls;
	}
private:
	static std::size_t moveCtorCalls;
	std::string s;
};

std::move를 쓸 수 있는 곳에 std::forward를 쓸 수 있지만 사용자가 명시적으로 std::move를 이용하여 이동을 수행하는 경우 std::move로 명시하는 것이 바람직하다.

 

- 정리

  • std::move는 오른값으로 무조건 캐스팅한다.
  • std::forward는 주어진 인수가 오른값에 묶인 경우에만 그것을 오른값으로 캐스팅한다.
  • std::move와 std:forward 둘 다 실행 시점에서 아무일도 하지않는다.

2. 보편 참조와 오른값 참조를 구별한다.

// 항목 24
void f(Widget && param);  // 오른값 참조
Widget&& var1 = Widget();  // 오른값 참조
auto&& var2 = var1;     // 오른값 참조 아님

template<typename T>
void f(std::vector<T> && param); // 오른값 참조 

template<typename T>
void f(T && param); // 오른값 참조 아님

 

"T&&"의 의미는 오른값 참조 또는 왼값 참조 중 하나이다. 모든 타입을 참조 형태로 묶을 수 있는데 (const lvalue -> const &, volatile -> volatile&, const rvalue -> const &&), 이를 보편 참조라고 한다.

 

- 보편 참조

template<typename T>
void f(T&& param);  // param은 보편 참조

auto&& var2 = var1; // var2는 보편 참조

두 문맥의 공통점은 형식 연역이 일어난다. 템플릿에서는 param에 대한 형식이 연역되고 var2 선언에서는 var2의 형식이 연역된다.

 

- 형식 연역이 일어나지 않는 경우

void f(Widget && param);  // 오른값 참조
Widget&& var1 = Widget();  // 오른값 참조

 형식 연역이 일어나지 않는 경우 보편 참조가 아니므로 항상 오른값 참조이다.

 

- 보편 참조는 형식 연역이 일어난다.

template<typename T>
void f(T && param); // param 보편 참조

Widget w;
f(w); // 왼값 -> 왼값 참조로 연역

f(std::move(w)); // 오른값 -> 오른값 참조로 연역

 

- 보편 참조의 형태에 관해

<T>&& 인수로 들어간 경우, 단지 <T> 인자로 결정되는 클래스에 대한 && 오른값을 지칭한다. 

template<typename T>
void f(std::vector<T> && param); // 오른값 참조

// 왼값을 오른값에 묶을수 없음
std::vector<int> v;
f(v);

const 한정사가 붙으면 보편 참조가 아니다.

template<typename T>
void f(const T && param); // param은 오른값 참조

템플릿 클래스의 템플릿 함수가 아닌 함수 선언 중 매개변수 타입이 T&& 인 경우

template<class T, class Allocator = allocator<T>>
class vector
{
public:
	void push_back(T&& x);
};

push_back의 매개변수 T&&는 인스턴스 형식에 의해 완전 결정되는 부분이다.

std::vector<Widget> v; // 템플릿 클래스의 인스턴스화

class vector<Widget, allocator<Widget>>
{
public:
  void push_back(Widget&& x);
};

 

- 템플릿 클래스안 템플릿 함수

Args는 템플릿 인수 T와 독립적인 템플릿 인수이기 때문에 Args&& 보편 참조 형태이다. (paramter pack로 이루어진)

template<class T, class Allocator = allocator<T>>
class vector {
public:
	template<class... Args>
	void emplace_back(Args&&... args);
};

- 정리

  • 함수 템플릿 매개변수의 형식이 T&& 형태이고 T가 연역된다면, 또는 객체를 auto&&로 선언한다면 그 매개변수나 객체는 보편 참조이다.
  • 형식 선언 형태가 정확히 T&& 가 아니면, 또는 형식 연역이 일어나지 않으면 T&&는 오른값 참조를 뜻한다. 
  • 오른값으로 초기화되는 보편 참조는 오른값 참조에 해당한다. 왼값으로 초기화되는 보편 참조는 왼값 참조에 해당한다.

3. 오른값 참조에는 std::move를 보편 참조에는 std::forward를 사용한다.

 

- 오른값 참조가 확실한 매개변수의 경우 명시적으로 오른값을 이용할 수 있는 std::move를 선호하도록 한다.

class Widget
{
private:
	std::string name;
	std::shared_ptr<SomeDataStructure> p;
public:
	Widget(Widget&& rhs) // rhs는 오른값 참조
		:
		name(std::move(rhs.name)),  // 이동 수행
		p(std::move(rhs.p))
	{}
};

 

- 보편 참조의 경우 해당 매개변수가 왼값인지 오른값인지 모르기 때문에 선택적으로 캐스팅되도록 한다.

class Widget
{
public:
	template<typename T>
	void SetName(T&& newName)
	{
		name = std::forward<T>(newName);
	}
};

 

보편 참조 매개변수에 std::move를 사용할 경우

class Widget
{
public:
	template<typename T>
	void SetName(T&& newName)
	{
		name = std::move(newName);
	}
};

std::string getWidgetName();

Widget w;

auto n = getWidgetName(); // n은 지역 변수

w.setName(n); // n을 w로 이동한다.

// n ???

n은 왼값이지만 move에 의해 SetName안에서 강제 이동되었고 n은 미지정 값이 된다.

 

보편 참조 대신 두개의 오버로딩을 사용

class Widget
{
public:
	void setName(const std::string& newName)
	{
		name = newName;
	}

	void setName(string&& newName)
	{
		name = std::move(newName)
	}
};

w.setName("Adela Novak");

const char[12] 형식의 "Adela Novak"를 매개 변수로 사용할 경우, 두번째 함수 즉 오른값 참조를 받는 함수가 선택될 것이다. 이 때 일어나는 과정은 다음과 같다.

1. 임시 string 객체가 생기며 string&& newName에 묶인다.

2. 이동 연산이 호출된다.

3. 임시 string 객체가 파괴된다.

 

vs 보편 참조와 std::forward

class Widget
{
public:
	template<typename T>
	void SetName(T&& newName)
	{
		name = std::forward<T>(newName);
	}
};

const char[12]&& newName로 바로 전달되고 이동 연산의 매개변수로 쓰이게된다.

 

보편 참조를 쓸 경우 매개 변수 수에 제약이 덜 하다.

template<class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);

template<class T, class... Args>
unique_ptr<T> make_unique(Args&&... args);

스마트 포인터의 팩토리 함수는 Args&& 보편 참조의 파라미터 팩을 받는다.

 

- std::move와 std::forwar의 효율적인 사용법

 

객체를 사용하기 전 move나 forward 호출로 이동되는 것을 막는다.

template<typename T>
void setSignText(T&& text)
{
	sign.setText(text);  // 수정하지는 않는다.

	auto now = std::chrono::system_clock::now();
	signHistory.add(now, std::forward<T>(text)); // 나중에 오른값으로 조건부 캐스팅
}

 

- 함수가 값으로 돌려주고 그것이 오른값 참조나 보편 참조에 묶인 객체라면 return 문에서 std::move나 std::forward를 쓰는 것을 고려한다.

값으로 돌려주고 오른값 참조에 묶인 경우, 수학 연산 라이브러리의 operator+의 예제

Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
	lhs += rhs;
	return std::move(lhs); // lhs는 이동한다.
}

Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
	lhs += rhs;
	return lhs; // lhs는 복사된다.
}

lhs는 연산자의 왼쪽에 오기때문에 보통 연산 후 사용되지 않는다. 그것이 게다가 오른값 참조라면 연산후 결과로 이동시키는 것에 문제가 없다. 만약 std::move를 사용하지 않는다면, lhs는 왼값이므로 결과값에 대해 복사가 일어난다.

 

값으로 돌려주고 보편 참조에 묶인 경우

template<typename T>
Fraction reduceAndCopy(T&& frac)
{
	frac.reduce();
	return std::forward<T>(frac);
}

frac이 오른값인 경우 이동하고 왼값의 경우 복사한다.

 

- 함수가 값을 반환하고 반환될 객체가 지역 변수인 경우 std::move가 의미가 없다.

Widget makeWidget()
{
	Widget w;
		
	//...
		
	return w;
}

Widget makeWidget()
{
	Widget w;

	// ...
		
	return std::move(w);
}

반환값 최적화(return value optimization, RVO)에 의해 지역 변수 w를 함수의 반환값을 위해 마련한 메모리 안에 생성한다면 w의 복사를 피할 수 있다.

 

그 조건은 1) 지역 객체의 형식이 함수의 반환 형식과 같아야하고 2) 지역객체가 바로 함수의 반환값이어야 한다.

Widget makeWidget()
{
	Widget w; // 지역 변수와 반환값이 타입과 일치
		
	//...
		
	return w;  // 복사가 일어날 것 같지만 RVO에 의해 복사가 일어나지 않는다.
}

Widget makeWidget()
{
	Widget w; // 지역 변수와 반환값이 타입과 일치

	// ...
		
	return std::move(w); // 하지만 그 실제 반환값은 Widget&&임으로 불일치 한다.
}

따라서 첫번쨰 makeWidget()은 반환값 최적화로 복사가 일어나지 않는다. 두번째 makeWidget()은 최적화의 시도가 오히려 RVO를 제한하여 이동이 실패할 경우 복사가 일어난다.

 

RVO를 하지 않는다고 해도 반환값은 컴파일러가 알아서 std::move(w)로 취급하기 때문에 return에 std::move를 쓰지 않는다.

// RVO를 하지 않는 경우
Widget makeWidget(Widget w) // 함수의 매개변수가 값이고 이것이 반환 될 경우
{
	//...
	return w; // 오른값으로 취급한다.
}
// 같은의미
Widget makeWidget(Widget w)
{
	return std::move(w);
}

 

- 정리

  • 오른값 참조나 보편 참조가 마지막으로 쓰이는 지점에서 오른값 참조에는 std::move를, 보편 참조에는 std::forward를 적용한다.
  • 결과를 값 전달 방식으로 돌려주는 함수가 오른값 참조나 보편 참조를 돌려줄 때에도 각각 std::move나 std::forward를 적용한다.
  • 반환값 최적화의 대상이 될 수 있는 지역객체에는 std::move와 std::forward를 적용하지 않는다.