씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)>
모두의 코드 씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)> 작성일 : 2018-03-27 이 글은 50581 번 읽혔습니다. 등에 대해 다룹니다. 안녕하세요 여러분! 지난번의 우
modoocode.com
위 글에서 좀 내리다 보면 perfect forwarding목차가 있고 여기서 std::vector의 push_back, emplace_back이야기를 하면서 이를 완벽전달과 연관지어서 설명을 하고 있고, 완벽전달에 대한 충분한 예시와 개념이 설명이 되어 있지만 본인은 왜 써야하는지, 왜 등장하게 되었는지 등의 이유가 없으면 잘 이해가 가지 않아, 위 글을 분석하고 완벽전달이 필요한 이유와 등장배경 대해서 추가를 하고 나름대로의 정리하려고 한다.
Universal Reference 등장 배경
모두의 코드님의 글을 읽다가 보면 'C++11에 rvalue 참조가 도입되기 전까지 해결할 수 없던 문제가 있었다.' 라고 하면서
아래 코드와 std::vector의 push_back과 emplace_back에 대해서 연관지어서 설명해주고 있다.
template <typename T>
void wrapper(T u) {
g(u);
}
위 함수는 인자로 전달 받은 u를 그대로 g라는 함수에 전달하는데 왜 필요한가? 에서 시작해서 push_back, emplace_back을 설명하는데 push_back, emplace_back이 wrapper함수와 연관이 있기 때문이다.
push_back은 vector에 A라는 객체를 추가하고 싶은 경우 객체의 생성자에 전달하고싶은 인자들을 함수에 전달하면, 알아서 vector의 맨 뒤에 추가를 해준다.
vec.push_back(A(1, 2, 3));
위처럼 객체를 생성한 뒤 인자로 전달 해주어야 한다. 이 과정에서 '불필요한 이동 혹은 복사가 발생한다는 것이 문제'라는 것이다.
그냥 vec의 맨뒤에 A의 생성자만 호출을 해서 뒤에 배치하면 되기 때문에 문제라는 것인데, 이것을 해결해주는게 emplace_back이라고 한다.
vec.emplace_back(1, 2, 3); // 위와 동일한 작업을 수행한다
emplace_back함수는 인자를 직접 전달 받아서, 내부에서 A의 생성자를 호출한 뒤에 이를 벡터 원소 뒤에 추가한다.
(최근에는 push_back 함수를 사용하더라도 컴파일러가 알아서 최적화를 해주기 때문에 불필요한 이동&복사를 수행하지 않고 emplace_back과 동일한 어셈블리를 생성한다. emplace_back은 예상치 못한 생성자가 호출 될 수 있으니 push_back을 사용하는 것이 낫다고 한다)
wrapper함수가 왜 필요한지에 대해서 예시를 들려고 push_back과 emplace_back에 대해서 설명한 것 뿐이며 말하고자 하는 요지는 'emplace_back 함수가 받은 인자들을 A의 생성자에 제대로 전달해야한다는 점' 에서 wrapper함수를 보여준 것이다.
즉, 왼값이 들어오면 왼값을 전달해야하고 오른값이 들어오면 오른값을 그대로 전달해야하는데 이게 C++11이전에는 불가능 했고 C++11이후 perfect forwarding으로 가능하다는 것이다.
C++11이전에는 왜 불가능 했는가?
universal reference(보편참조)가 없는 C++11이전의 경우 왼값은 왼값으로 전달하고 오른값은 오른값으로 전달하기 위해 아래와 같이 코드를 작성할 수 있었다고 한다.
template <typename T>
void wrapper(T u) {
g(u);
}
class A {};
void g(A& a) { std::cout << "좌측값 레퍼런스 호출" << std::endl; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출" << std::endl; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출" << std::endl; }
int main() {
A a;
const A ca;
std::cout << "원본 --------" << std::endl;
g(a);
g(ca);
g(A());
std::cout << "Wrapper -----" << std::endl;
wrapper(a);
wrapper(ca);
wrapper(A());
}
원본 --------
좌측값 레퍼런스 호출
좌측값 상수 레퍼런스 호출
우측값 레퍼런스 호출
Wrapper -----
좌측값 레퍼런스 호출
좌측값 레퍼런스 호출
좌측값 레퍼런스 호출
위처럼 출력이 되는데 이상하다. wrapper를 통해 호출한 경우 모두 '좌측값 레퍼런스 호출'로 출력문이 찍힌 것인데
이는 C++컴파일러가 템플릿 타입을 추론할 때, 템플릿 인자 T가 레퍼런스가 아닌 일반적인 타입이라면 const를 무시하기 때문이다.
즉 wrapper(a), wrapper(ca), wrapper(A()) 를 호출 할때 모두 class A로 추론됬다는 것이다.
template <typename T>
void wrapper(T& u) {
g(u);
}
그래서 위처럼 T&를 붙인 경우는 어떠한지 살펴봤는데 컴파일 에러가 발생한다. g(A())에서 발생하는 것인데, A()는 const 속성이 없으므로 템플릿 인자 추론에 따라 T가 class A로 추론되는데 A&는 우측값 참조가 될 수 없기때문에 컴파일 에러가 발생한다는 것이다. (임시객체인데 임시객체에 &를 붙일 수 없기때문이다. &3 -> 이게 말이 되는 표현식인가? 말이 안됨)
그래서 우측값 참조를 받을 수 있도록 하기 위해서 wrapper함수를 const A&를 받는 버젼과 A&를 받는 버젼을 따로 만들어 주면 어떨까? 해서 아래와 같이 만들어준 것이다. (그래도 문제가 있다)
#include <iostream>
#include <vector>
template <typename T>
void wrapper(T& u) {
std::cout << "T& 로 추론됨" << std::endl;
g(u);
}
template <typename T>
void wrapper(const T& u) {
std::cout << "const T& 로 추론됨" << std::endl;
g(u);
}
class A {};
void g(A& a) { std::cout << "좌측값 레퍼런스 호출" << std::endl; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출" << std::endl; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출" << std::endl; }
int main() {
A a;
const A ca;
std::cout << "원본 --------" << std::endl;
g(a);
g(ca);
g(A());
std::cout << "Wrapper -----" << std::endl;
wrapper(a);
wrapper(ca);
wrapper(A());
}
원본 --------
좌측값 레퍼런스 호출
좌측값 상수 레퍼런스 호출
우측값 레퍼런스 호출
Wrapper -----
T& 로 추론됨
좌측값 레퍼런스 호출
const T& 로 추론됨
좌측값 상수 레퍼런스 호출
const T& 로 추론됨
좌측값 상수 레퍼런스 호출
wrapper(a), wrapper(ca)의 경우 예상한 것처럼 로그가 찍히는데 wrapper(A())가 '우측값 레퍼런스 호출'이 찍힐 것을 예상했지만 '좌측값 상수 레퍼런스 호출'로그가 찍혔다.
이는 A()가 const T&로 추론되면서 g(const T&)를 호출했기 때문이다. 왜냐하면 wrapper안에 u라는 것 자체는 좌측값이기 때문이다.
이런 문제는 일반적인 레퍼런스가 우측값을 받을 수 없기 때문에 발생한 문제이고, const T&로 받겠다고 해도 상수가 아닌 일반 레퍼런스도 상수 레퍼런스로 캐스팅되어 들어간다는 문제가 있다.
위는 모두의 코드님 글이고 좀더 쉽게 설명을 하자면
"T&로 받으면 우측값을 못받는다 -> wrapper(A())는 컴파일 에러, const T&로 받으면 우측값을 받을 수는 있지만 g(u)호출시 u는 좌측값이므로 g(A&&)를 호출할 수 없다."
그래서 등장한 것이 'universal reference(보편 참조)' 이다.
Universal Referecne란
보편참조 등장 배경과 C++11이전에 왜 보편참조 없이 템플릿 타입 추론이 불가능 했는지와 문제점을 알아봤으니 보편참조가 무엇인지 알아보자.
template <typename T>
void wrapper(T&& u) {
g(std::forward<T>(u));
}
class A {};
void g(A& a) { std::cout << "좌측값 레퍼런스 호출" << std::endl; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출" << std::endl; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출" << std::endl; }
int main() {
A a;
const A ca;
std::cout << "원본 --------" << std::endl;
g(a);
g(ca);
g(A());
std::cout << "Wrapper -----" << std::endl;
wrapper(a);
wrapper(ca);
wrapper(A());
}
우선 wrapper함수에 T&& u를 붙이고 g함수에 u를 넘길때 forward<T>를 붙여서 넘기면 우리가 처음에 원하던 출력문을 볼 수 있다.
wrapper함수에 붙은 '&&'와 'forward'가 무엇인지 순서대로 알아보자.
우선 wrapper함수에 붙은 '&&'는 우측값 참조와 다른 것이다.
void show_value(int&& t) { std::cout << "우측값 : " << t << std::endl; }
int main() {
show_value(5); // 우측값 ok!
int x = 3;
show_value(x); // 애러
}
show_value함수는 '우측값'만 받을 수 있는 함수이기 때문에 show_value(x)를 호출하는 코드에서 컴파일 에러가 발생한다.
반면 아래의 wrapper함수는 우측값 뿐만 아니라 좌측값도 받을 수 있다.
template <typename T>
void wrapper(T&& u) {}
이게 가능한 것은 C++11에서의 레퍼런스 겹침 규칙에 따라 T의 타입을 추론하기 때문이다.
typedef int& T;
T& r1; // int& &; r1 은 int&
T&& r2; // int & &&; r2 는 int&
typedef int&& U;
U& r3; // int && &; r3 는 int&
U&& r4; // int && &&; r4 는 int&&
모두의 코드님은 위의 예시를 통해 설명을 해주고 계신데, 구글링하다가 보다 쉽게 T타입을 추론하는 방법을 알려드리고자 한다.
&가 작은 쪽으로 해석하면 된다. 즉 typedef int&& U를 정의하고 U&를 하는 경우 int&& &; 가 되는데 레퍼런스가 작은게 &(1개)이니까 int&로 레퍼런스 겹침 규칙이 적용된다는 것이다.
wrapper(a);
wrapper(ca);
wrapper(A());
위 코드의 경우 T가 각각 A&와 const A&로 추론되고 A()의 경우 T가 A로 추론된다.
문제는 T가 우측값으로 추론되길 원한 것인데 T 가 A로 추론된 것인데 이는 u가 좌측값이라 그렇다.
이것을 해결해주는게 forward이다.
template <class S>
S&& forward(typename std::remove_reference<S>::type& a) noexcept {
return static_cast<S&&>(a);
}
forward함수는 위처럼 생겼고 std::remove_reference는 T타입의 레퍼런스를 지워주는 템플릿 메타함수이다.
만약 T가 A&라면은 아래처럼 바뀌고
A&&& forward(typename std::remove_reference<A&>::type& a) noexcept {
return static_cast<A&&&>(a);
}
레퍼런스 겹침 규칙에 따라 반환 타입인 'A&&&'는 A& && -> A&가 되고 static_cast<A&&&>가 static_cast<A&>가 된다.
아래처럼 말이다.
A& forward(A& a) noexcept { return static_cast<A&>(a); }
만약 T가 A라면, 아래처럼 되기 때문에 우측값으로 캐스팅을 해주게 된다.
A&& forward(A& a) noexcept { return static_cast<A&&>(a); }
따라서 보편 참조로 u를 받아주고 forward를 통해 u를 넘겨주게 되면 좌측값은 좌측값으로 넘겨주고 우측값은 우측값으로 성공적으로 넘겨줄 수 있게 된 것이다.
'CPP' 카테고리의 다른 글
| [CPP] std::move와 const제약 (0) | 2025.05.29 |
|---|---|
| [C++] std::vector resize와 reserve의 차이점 (0) | 2025.05.06 |
| Stomp Allocator (0) | 2025.04.04 |
| [C++] 다형성과 가상 소멸자 (0) | 2025.03.25 |
| [CPP] static_assert (1) | 2024.02.07 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!