C++11의 emplace 계열 메서드를 계기로, 완벽 전달을 가능하게 하는 참조 붕괴와 T&&의 특수 타입 추론 규칙, std::forward의 동작 원리와 활용 예를 설명한다.
C++11의 새로운 기능 중 코드 효율을 높이기 위한 것 하나로 컨테이너의 emplace 계열 메서드가 있다. 예를 들어 std::vector에는 push_back에 대응하는 emplace_back, insert에 대응하는 emplace가 있다.
다음은 이 메서드들이 가져다주는 이점을 간단히 보여주는 예시다:
class MyKlass {
public:
MyKlass(int ii_, float ff_) {...}
private:
{...}
};
some function {
std::vector<MyKlass> v;
v.push_back(MyKlass(2, 3.14f));
v.emplace_back(2, 3.14f);
}
MyKlass의 생성자와 소멸자 실행을 추적해 보면, push_back 호출에서는 대략 다음과 같은 일이 일어난다:
꽤 많은 일이 벌어진다. 하지만 사실 대부분은 불필요하다. push_back에 전달되는 객체는 명백히 rvalue이며, 해당 문장이 끝나면 더 이상 존재하지 않는다. 임시 객체를 만들고 파괴할 이유가 없다 — 벡터 내부에서 바로 객체를 생성하면 되지 않을까?
emplace_back이 정확히 그 일을 한다. 위의 v.emplace_back(2, 3.14f) 호출에서는 단 하나의 생성자 호출만 보인다. 이는 벡터 "내부"에서 생성된 객체다. 임시 객체가 필요 없다.
emplace_back은 MyKlass의 생성자를 직접 호출하고 그 인자를 생성자에 전달(forward)함으로써 이를 이룬다. 이 묘기는 C++11의 두 가지 새로운 기능, 즉 가변 길이 템플릿과 완벽 전달(perfect forwarding) 덕분에 가능하다. 이 글에서는 완벽 전달이 어떻게 동작하는지, 그리고 어떻게 사용하는지 설명한다.
일반적인 형식 매개변수 E1, E2, ..., En을 갖는 임의의 함수 호출 func(E1, E2, ..., En)이 있다고 하자. wrapper(E1, E2, ..., En)가 func(E1, E2, ..., En)과 동등하게 동작하도록 하는 함수 래퍼를 만들고 싶다. 다시 말해, 어떤 함수로 자신의 매개변수를 "완벽하게" 전달하는 일반적인 매개변수의 함수를 정의하고 싶다.
이 정의를 좀 더 구체적으로 떠올리기 위해 앞서 언급한 emplace_back을 생각해 보자. vector<T>::emplace_back은 T가 어떻게 생겼는지 알지 못한 채로, 자신의 매개변수를 T의 생성자로 전달한다.
다음으로, C++11 이전에 우리가 이 문제에 어떻게 접근했을지를 몇 가지 예로 보여 주겠다. 단순화를 위해 가변 템플릿은 제쳐두고, 두 개의 인자만 전달하면 된다고 가정하자.
가장 먼저 떠오르는 접근은 다음과 같다:
template <typename T1, typename T2>
void wrapper(T1 e1, T2 e2) {
func(e1, e2);
}
이 방식은 func가 참조로 매개변수를 받는 경우 분명히 동작하지 않는다. wrapper가 값 전달 단계를 도입하기 때문이다. 만약 func가 참조로 받은 매개변수를 수정한다면, 그 수정은 wrapper의 호출자에게는 보이지 않는다(수정되는 것은 wrapper가 만든 복사본뿐이다).
좋다. 그러면 wrapper가 매개변수를 참조로 받게 만들 수 있다. wrapper 내부에서 func를 호출할 때 필요한 복사가 일어나므로, func가 값으로 매개변수를 받더라도 문제가 되지 않는다.
template <typename T1, typename T2>
void wrapper(T1& e1, T2& e2) {
func(e1, e2);
}
하지만 다른 문제가 있다. rvalue는 비-const 참조 매개변수에 바인딩될 수 없으므로, 다음과 같은 지극히 타당한 호출이 실패한다:
wrapper(42, 3.14f); // 오류: rvalue로 비-const 참조를 초기화할 수 없음
wrapper(i, foo_returning_float()); // 동일한 오류
그리고 참조 매개변수를 const로 만드는 것도 해결책이 아니다. func가 정당하게 비-const 참조 매개변수를 원할 수도 있기 때문이다.
남는 것은 일부 라이브러리가 택했던 무식한 방법이다. const와 비-const 참조 모두에 대해 오버로드를 정의하는 것이다:
template <typename T1, typename T2>
void wrapper(T1& e1, T2& e2) { func(e1, e2); }
template <typename T1, typename T2>
void wrapper(const T1& e1, T2& e2) { func(e1, e2); }
template <typename T1, typename T2>
void wrapper(T1& e1, const T2& e2) { func(e1, e2); }
template <typename T1, typename T2>
void wrapper(const T1& e1, const T2& e2) { func(e1, e2); }
조합 폭발이다. 전달해야 할 매개변수의 수를 조금만 늘려도 이 방식이 얼마나 끔찍해지는지 상상할 수 있을 것이다. 설상가상으로 C++11은 rvalue 참조를 추가했고(이 역시 올바르게 전달하고 싶을 것이다), 이 방법은 전혀 확장 가능하지 않다.
C++11이 완벽 전달 문제를 어떻게 해결하는지 설명하려면, 먼저 언어에 추가된 두 가지 새로운 규칙을 이해해야 한다.
참조 붕괴(reference collapsing)는 설명하기 더 쉽다. 이 이야기부터 시작하자. C++에서는 참조의 참조를 만드는 것이 불법이다. 하지만 템플릿과 타입 추론의 문맥에서는 때때로 이런 일이 생길 수 있다:
template <typename T>
void baz(T t) {
T& k = t;
}
다음과 같이 이 함수를 호출하면 어떻게 될까:
int ii = 4;
baz<int&>(ii);
템플릿 인스턴스화에서 T는 명시적으로 int&로 설정된다. 그렇다면 내부의 k는 어떤 타입일까? 컴파일러가 "보는" 것은 int&&이다. 사용자가 코드에 명시적으로 쓸 수 있는 표현은 아니지만, 컴파일러는 여기서 단일 참조를 도출한다. 사실 C++11 이전에는 이것이 표준화되어 있지 않았지만, 이런 경우가 템플릿 메타프로그래밍에서 가끔 발생하기 때문에 많은 컴파일러가 이런 코드를 받아들였다. C++11에서 rvalue 참조가 추가되면서, 여러 참조 타입이 합성될 때 무엇이 일어나는지를 정의하는 것이 중요해졌다(예: int&&&는 무엇인가?).
결과는 바로 "참조 붕괴" 규칙이다. 규칙은 아주 간단하다. &가 항상 이긴다. 즉 &&는 &가 되고, &&&와 &&&&도 &가 된다. &&가 붕괴 결과로 남는 유일한 경우는 &&&&뿐이다. &를 1, &&를 0으로 보고 논리 OR처럼 생각해도 좋다.
이 글과 관련된 C++11의 또 다른 추가 사항은 특정 경우의 rvalue 참조에 대한 특수 타입 추론 규칙이다 [1]. 다음과 같은 함수 템플릿이 있다고 하자:
template <class T>
void func(T&& t) {
}
여기서 T&&에 속지 말자 — 이 경우 t는 rvalue 참조가 아니다 [2]. 타입을 추론하는 문맥에서 T&&는 특별한 의미를 갖는다. func가 인스턴스화될 때, T는 func에 전달된 인자가 lvalue인지 rvalue인지에 따라 달라진다. 만약 타입 U의 lvalue라면 T는 U&로 추론된다. rvalue라면 T는 U로 추론된다:
func(4); // 4는 rvalue: T는 int로 추론됨
double d = 3.14;
func(d); // d는 lvalue: T는 double&로 추론됨
float f() {...}
func(f()); // f()는 rvalue: T는 float으로 추론됨
int bar(int i) {
func(i); // i는 lvalue: T는 int&로 추론됨
}
이 규칙은 낯설고 이상하게 느껴질 수 있다. 실제로 그렇다. 하지만 이 규칙이 완벽 전달 문제를 해결하기 위해 설계되었다는 사실을 깨달으면 의미가 보이기 시작한다.
이제 원래의 wrapper 템플릿으로 돌아가 보자. C++11에서는 다음과 같이 작성해야 한다:
template <typename T1, typename T2>
void wrapper(T1&& e1, T2&& e2) {
func(forward<T1>(e1), forward<T2>(e2));
}
그리고 forward는 다음과 같다:
template<class T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
return static_cast<T&&>(t);
}
template <class T>
T&& forward(typename std::remove_reference<T>::type&& t) noexcept {
return static_cast<T&&>(t);
}
다음과 같이 호출한다고 하자:
int ii ...;
float ff ...;
wrapper(ii, ff);
첫 번째 인자를 살펴보자(두 번째도 마찬가지로 처리된다). ii는 lvalue이므로, 특수 추론 규칙에 따라 T1은 int&로 추론된다. 따라서 func(forward<int&>(e1), ...) 호출이 만들어진다. 그러므로 forward는 int&로 인스턴스화되어 다음 버전이 선택된다:
int& && forward(int& t) noexcept {
return static_cast<int& &&>(t);
}
이제 참조 붕괴 규칙을 적용해 보자:
int& forward(int& t) noexcept {
return static_cast<int&>(t);
}
즉, 인자는 lvalue에 필요한 대로 참조로 func에 전달된다.
다른 경우를 보자:
wrapper(42, 3.14f);
여기서 인자들은 rvalue이므로, T1은 int로 추론된다. 결과적으로 func(forward<int>(e1), ...) 호출이 만들어진다. 따라서 forward는 int로 인스턴스화되어 다음 버전이 선택된다 [3]:
int&& forward(int&& t) noexcept {
return static_cast<int&&>(t);
}
forward는, 래퍼에 전달된 인자의 종류(lvalue 또는 rvalue)에 따라 T가 U& 또는 U&&로 추론될 수 있을 때 static_cast<T&&>(t)를 감싸는 멋진 래퍼라고 볼 수 있다. 이제 wrapper는 모든 종류의 전달을 깔끔하게 처리하는 단일 템플릿이 된다.
forward 템플릿은 C++11의 <utility> 헤더에 std::forward라는 이름으로 존재한다.
또 한 가지 언급하고 싶은 점은 std::remove_reference<T>의 사용이다. 사실 곰곰이 생각해 보면, forward는 이것 없이도 동작할 수 있다. 참조 붕괴가 이미 일을 해주기 때문에 std::remove_reference<T>는 중복처럼 보인다. 이건 T& t를 타입 비추론(non-deducing) 문맥으로 바꾸기 위해 존재한다(C++ 표준 14.8.2.5절에 따라), 그 결과 std::forward를 호출할 때 템플릿 인자를 명시적으로 제공하도록 강제한다.
Scott Meyers는 강연, 블로그 글, 책에서 타입 추론 문맥에 나타나는 rvalue에 "유니버설 참조(universal references)"라는 이름을 붙였다. 이것이 유용한 암기법인지 여부는 사람마다 다르다. 개인적으로는 새 "Effective C++"의 해당 장을 처음 읽었을 때 이 주제에 꽤나 혼란스러웠다. 나중에서야(참조 붕괴와 특수 추론 규칙이라는) 기반 메커니즘을 이해하고 나서야 다소 명확해졌다.
함정은, "유니버설 참조" [4]라는 표현이 분명히 "타입 추론 문맥에서의 rvalue 참조"라고 길게 말하는 것보다 간결하고 좋아 보이지만, 실제로 어떤 코드 조각을 진짜로 이해하고 싶을 때(그냥 보일러플레이트를 화물 숭배식으로 베끼는 게 아니라면) 그 긴 정의를 피할 수 없다는 점이다.
완벽 전달은 매우 유용하다. 일종의 고차(higher-order) 프로그래밍을 가능하게 하기 때문이다. 고차 함수는 다른 함수를 인자로 받거나 반환할 수 있는 함수다. 완벽 전달 없이 고차 함수는 번거롭다. 래핑한 함수로 인자를 편리하게 전달할 방법이 없기 때문이다. 여기서 "함수"에는 클래스도 포함되는데, 생성자 역시 함수이기 때문이다.
글의 서두에서 컨테이너의 emplace_back을 언급했다. 또 하나의 좋은 예는 이전 글에서 설명한 make_unique이다:
template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args)
{
return unique_ptr<T>(new T(std::forward<Args>(args)...));
}
거기서는 낯선 && 문법은 잠시 잊고 가변 템플릿 팩에 집중해 달라고 부탁했지만, 이제는 코드를 완전히 이해하는 데 아무 문제가 없다. 말할 필요도 없이, 완벽 전달과 가변 템플릿은 매우 자주 함께 사용된다. 일반적으로 우리가 전달하는 함수나 생성자가 몇 개의 인자를 받는지 알 수 없기 때문이다.
완벽 전달의 훨씬 더 복잡한 사용 예로는 std::bind도 살펴볼 만하다.
다음은 이 글을 준비하며 도움이 되었던 자료들이다:
[1] 이 규칙은 auto나 decltype 같은 다른 상황에도 적용된다. 여기서는 템플릿의 경우만 소개한다.
[2] C++ 위원회가 이 경우에 대해 다른 문법을 택하지 않고 &&의 의미를 오버로드한 것은 아쉽다고 생각한다. 상대적으로 흔치 않은 사용처처럼 보이기는 하겠지만(언어 문법 변경은 위원회가 최대한 피하려 한다), 내 생각에는 지금 상황이 너무 혼란스럽다. Scott Meyers도 강연과 블로그 댓글에서 3년이 지난 지금도 이 내용이 여전히 "스며드는 중"이라고 인정했다. 그리고 Bjarne Stroustrup도 "The C++ Programming Language" 4판에서 std::forward를 설명하며 호출 시 템플릿 인자를 명시하는 것을 빠뜨리는 실수를 했다. 이건 복잡한 주제다!
[3] 실제 테스트 코드에서는 단순 정수 rvalue에 대해 int&& 오버로드가 아니라 int& 오버로드가 선택되는 것처럼 보인다. 이는 단순 타입이 항상 값으로 전달되는 특성 때문일 수 있다. 왜 이런 일이 발생하는지 알아내면 알려 주기 바란다.
[4] "Forwarding references"라는 이름도 다른 곳에서 사용되는 것을 들어봤다.
댓글은 이메일로 보내 주세요.